diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index e9e848da6f..8b1db61f46 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; message += - `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + - `this PR is on the large side `; + `Hey @${prAuthor}, thanks for the contribution. This PR is on the ` + + `large side `; if (tooManyLabels && tooManyChanges) { message += @@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, } message += `, which makes it harder for maintainers to review.\n\n`; + let condition; + if (tooManyLabels && tooManyChanges) { + condition = + `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` + + `at most ${MAX_LABELS} components/areas`; + } else if (tooManyLabels) { + condition = `touches at most ${MAX_LABELS} components/areas`; + } else { + condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`; + } message += - `Smaller, focused PRs tend to be reviewed much faster since they ` + - `fit into the short gaps between other maintainer work; large ones ` + - `often have to wait for a rare long uninterrupted block of time. ` + - `If you can break this up into smaller pieces that can be reviewed ` + - `independently, it will almost certainly land faster overall.\n\n`; + `**This PR will not be reviewed until it ${condition}.** We know ` + + `that is not what you want to hear after this much work, and we want ` + + `to be upfront so you do not wait for a review that is not coming: ` + + `please split the change. The label is removed automatically once ` + + `the PR ${condition}.\n\n`; message += - `Before putting more time in, it's also worth popping into ` + - `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + - `you scope things and flag anything already in flight.\n\n`; + `There is no exception you can request. Rarely, a maintainer agrees in ` + + `\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` + + `genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` + + `but that is rare enough that you should plan on splitting rather than ` + + `wait for it. Smaller, focused PRs are picked up much faster because ` + + `they fit into the short gaps between other maintainer work. For a new ` + + `component, the first PR brings the hub and a single platform (for ` + + `example \`sensor\`); each further platform is its own PR on top of it.\n\n`; message += `For more details (including how to split the work up), see: ` + `https://developers.esphome.io/contributing/submitting-your-work/` + @@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess) } module.exports = { + generateReviewMessages, handleReviews, handleMaintainerAccessComment }; diff --git a/.github/scripts/auto-label-pr/tests/reviews.test.js b/.github/scripts/auto-label-pr/tests/reviews.test.js new file mode 100644 index 0000000000..2390d2a073 --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/reviews.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateReviewMessages } = require('../reviews'); + +const MAX_LABELS = 15; +const TOO_BIG_THRESHOLD = 1000; + +function tooBigMessage(labelCount, additions) { + const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }]; + const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD); + assert.equal(messages.length, 1); + return messages[0]; +} + +describe('too-big review message', () => { + it('names the line cap when the PR is too long', () => { + const message = tooBigMessage(3, 1500); + assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./); + assert.match(message, /removed automatically once the PR is at most 1000 lines/); + assert.doesNotMatch(message, /components\/areas\./); + }); + + it('names the area cap when the PR touches too many areas but is short', () => { + const message = tooBigMessage(20, 200); + assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./); + assert.doesNotMatch(message, /until it is at most 1000 lines/); + }); + + it('names both caps when both are exceeded', () => { + const message = tooBigMessage(20, 1500); + assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./); + }); + + it('tells new-component authors to bring one platform first', () => { + assert.match(tooBigMessage(3, 1500), /hub and a single platform/); + assert.match(tooBigMessage(3, 1500), /mega-pr/); + }); +}); diff --git a/CODEOWNERS b/CODEOWNERS index 4264479349..b45816640e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,6 +156,7 @@ 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/ee895/* @Stock-M @@ -183,6 +184,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 @@ -303,6 +305,7 @@ esphome/components/ld2410/* @regevbr @sebcaps 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 +314,9 @@ 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/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b @@ -347,7 +353,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 @@ -413,6 +418,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 +437,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 @@ -503,6 +510,7 @@ 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 @@ -639,7 +647,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..0c164893df 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 " @@ -1356,7 +1378,12 @@ def _upload_via_native_api( 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) @@ -1738,7 +1765,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 +1866,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 @@ -2216,6 +2297,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 +2474,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 +2559,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 +2789,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 6b6af4ed72..393a79422b 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 @@ -465,6 +465,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. @@ -515,7 +531,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: @@ -724,7 +740,7 @@ async def condition_group_to_code( if len(conditions) == 1: return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -840,11 +856,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -867,8 +885,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -897,7 +916,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -1033,21 +1054,79 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef unsafe. + + Accepts an action list, a single action, or an automation. Only the keys of + action entries are looked up as action names; the options of an action are + only searched for nested action lists (``then:``, ``else:``, ...), so an + option that shares a name with an action, such as the ``delay:`` option of + ``speaker_source.set_playlist_delay``, is not mistaken for that action. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) if isinstance(actions, dict): - for key in actions: + for key, options in actions.items(): if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous: return True - return any( - has_non_synchronous_actions(v) - for v in actions.values() - if isinstance(v, (list, dict)) - ) + if _options_have_non_synchronous_actions(options): + return True return False +def _options_have_non_synchronous_actions(options: Any) -> bool: + """Search an action's options for nested action lists, without treating option keys as actions.""" + if isinstance(options, list): + return has_non_synchronous_actions(options) + if isinstance(options, dict): + return any(_options_have_non_synchronous_actions(v) for v in options.values()) + return False + + +def validate_no_deferring_actions( + context: str, data: str = "the trigger data" +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that rejects deferring actions in a validated automation. + + Use it for triggers whose arguments only live while the callback runs (views into a reused + buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later. + + :param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``. + :param data: What is only valid while the automation runs, for the error message. + """ + + def validator(value: ConfigType) -> ConfigType: + if has_non_synchronous_actions(value): + raise cv.Invalid( + f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in " + f"{context}: {data} is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + return validator + + +def validate_synchronous_automation( + context: str, + data: str = "the trigger data", + extra_schema=None, + extra_validators=None, + single: bool = False, +) -> cv.All: + """``validate_automation()`` that also rejects deferring actions. + + See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are + passed to ``validate_automation()``. + """ + return cv.All( + validate_automation( + extra_schema=extra_schema, + extra_validators=extra_validators, + single=single, + ), + validate_no_deferring_actions(context, data), + ) + + async def _new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: @@ -1055,7 +1134,8 @@ async def _new_automation( templ = cg.TemplateArguments(*(arg[0] for arg in args)) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + for action in actions: + cg.add(obj.add_action(action)) return obj diff --git a/esphome/bundle.py b/esphome/bundle.py index b633c5ca4f..45d9a74b49 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 diff --git a/esphome/codegen.py b/esphome/codegen.py index 4db01a2174..9ed36de0d5 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401 safe_exp, set_cpp_standard, shared_progmem_array, + shared_progmem_string, statement, static_const_array, static_function, 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/api/__init__.py b/esphome/components/api/__init__.py index f538c0b5e5..9611228ba1 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import Expression, MockObj, TemplateArgsType import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash +from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType @@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _add_action_strings( - index: int, strings: list[str | None], interned: dict[str, MockObj] -) -> MockObj: - """Emit the PROGMEM string table for one action. - - Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical - strings are shared between actions through `interned`. - """ - entries: list[MockObj] = [] - for string in strings: - if string is None: - entries.append(cg.nullptr) - continue - if (var := interned.get(string)) is None: - var = interned[string] = cg.progmem_array( - ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), - string, - ) - entries.append(var) +def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: + """Emit the PROGMEM string table for one action.""" return cg.progmem_array( ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), - entries, + [cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings], ) @@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") - interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None: service_template_args.append(native) func_args.append((native, name)) strings = _action_strings(conf, has_metadata) - table = _add_action_strings(index, strings, interned_strings) + table = _add_action_strings(index, strings) if CORE.is_esp8266: scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types @@ -682,7 +664,7 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: @@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) -def _field_string(value: str) -> Expression: - # ESP8266 can only keep a string in flash as its own PROGMEM array - literal = cg.RawExpression(cpp_string_escape(value)) - if CORE.is_esp8266: - return cg.shared_progmem_array("ha_field_str", cg.char, literal) - return literal - - async def _new_service_call_action( server_id: ID, action_id: ID, @@ -858,12 +832,12 @@ async def _new_service_call_action( ) async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: - key_exp = cg.nullptr if key is None else _field_string(key) + key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key) if cg.is_template(value): # output_type=None lets lambdas return numbers or char pointers; C++ converts them lam = await cg.process_lambda(value, args, return_type=output_type) return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") - return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr) entries = [await field(None, service, cg.std_string)] for group in groups: diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 22446ca8e6..d8099dd77f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -444,7 +444,7 @@ message DeviceWizardRequest { // {"version": 1, // "pages": [{"title": "...", "description": "...", // "entities": [{"key": 123, "device_id": 456, "description": "..."}], -// "inputs": [{"key": 789, "description": "...", +// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, // "entity_filters": [{"integration": "...", "domain": ["..."], // "device_class": ["..."], "supported_features": ["..."]}]}]}]} // @@ -468,6 +468,14 @@ message DeviceWizardRequest { // an entity matches a filter when it satisfies every field that is set, and an entity // that matches any one of the filters is accepted. // +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// // The device keeps the entity id of an input in RAM only and does not store it across // restarts. The client therefore stores the choice itself and, after every connection // and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest @@ -483,8 +491,13 @@ message DeviceWizardResponse { // Sets the entity id of a wizard input (see WizardInputField.key). Clients send // this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no -// reply. The device ignores a request whose key matches no input or whose entity_id -// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// reply. The device ignores a request whose key matches no input. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list in RAM, // not across restarts, and when the input belongs to a homeassistant platform entity // it subscribes to that entity's state. Clients send this for every input right // after connecting, before SubscribeHomeAssistantStatesRequest. A request that @@ -496,7 +509,7 @@ message WizardInputSetRequest { option (ifdef) = "USE_API_WIZARD_INPUTS"; fixed32 key = 1; - string entity_id = 2 [(max_data_length) = 255]; + string entity_id = 2 [(max_data_length) = 4095]; } message ListEntitiesRequest { @@ -2557,7 +2570,7 @@ message ListEntitiesEventResponse { EntityCategory entity_category = 7; string device_class = 8 [(max_data_length) = 47]; - repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; + repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message EventResponse { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e53428d423..c8a5e8a612 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - const FixedVector *event_types{}; + const ConstVector *event_types{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); 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/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 10a5cbc70c..9467efcfa0 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { ESPHOME_LOG_TAG(TAG, "api.wizard"); #ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_INPUTS + static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; } const char *wizard_set_input(const WizardInputSetRequest &msg) { - if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { - ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); - return nullptr; - } + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { // The table is in flash, which ESP8266 can only read through progmem_memcpy WizardInputEntry entry; progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); if (entry.key != msg.key) continue; - memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); - entry.entity_id[msg.entity_id.size()] = '\0'; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; return entry.entity_id; } - ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); return nullptr; } #endif // USE_API_WIZARD_INPUTS diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 7d5a566b55..6bdc1654bc 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 3ab80a063e..8e5c808e24 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -20,6 +20,8 @@ from esphome.const import ( CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL, + CONF_MAX_LENGTH, + CONF_MULTIPLE, CONF_NAME, CONF_PAGES, CONF_PLATFORM, @@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput") WIZARD_ENTITY_ID_BUFFER_SIZE = ( 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes ) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 # One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, # Noise) and footer (16 bytes, Noise MAC) WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 @@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema( ) +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + # An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no # entity_id of its own: Home Assistant sets it WIZARD_INPUT_SCHEMA = cv.All( @@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All( cv.Optional(CONF_ID): cv.declare_id(WizardInput), cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH + ), cv.Optional(CONF_TARGET): cv.Schema( { cv.Required(CONF_ENTITY): cv.All( @@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]: defines.add("USE_API_WIZARD_LINKED_INPUTS") if any(CONF_ID in conf for conf in inputs): defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_INPUTS") return defines @@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} if description := conf.get(CONF_DESCRIPTION): document[CONF_DESCRIPTION] = description + if conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length if filters := _wizard_input_filters(conf, config): document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] return document @@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi {"version": 1, "pages": [{"title": "...", "description": "...", "entities": [{"key": 123, "device_id": 456, "description": "..."}], - "inputs": [{"key": 789, "description": "...", + "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, "entity_filters": [{"integration": "...", "domain": ["..."], "device_class": ["..."], "supported_features": ["..."]}]}]}]} @@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi the device it belongs to (esphome/core/config.py), and is left out for the main device. - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. - entity_filters are the filters of the input, or the default filters of a linked switch, number, text, select or button. """ @@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None: input_id = _wizard_input_id(conf) # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. buffer = _wizard_buffer_name(input_id) - cg.add_global( - cg.RawStatement( - f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' - ) - ) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) if CONF_ID in conf: cg.new_Pvariable(input_id, cg.RawExpression(buffer)) - entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}")) if entries: cg.extern_progmem_array( "esphome::api::API_WIZARD_INPUTS", 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/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index 1553f33618..e18325303c 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; - // Calculate checksum and compare with signal value. - for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) - checksum += remote_state[i]; + const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1); if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 60e20aa193..109a22c7bc 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index b0b9bb1c17..e8dc09abba 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -51,12 +51,8 @@ void BL0939::loop() { } bool BL0939::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0939_READ_COMMAND; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } @@ -120,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_); @@ -135,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/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index a8b470de7e..ac1386df03 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -44,13 +44,9 @@ void BL0940::loop() { } bool BL0940::validate_checksum_(DataPacket *data) { - uint8_t checksum = this->read_command_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 7d0eff8c9d..7da8dcca54 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -70,13 +70,10 @@ void BL0942::loop() { } bool BL0942::validate_checksum_(DataPacket *data) { - uint8_t checksum = BL0942_READ_COMMAND | this->address_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^ + 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 54f111e4af..5d43162241 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener from esphome.components.uart import ( UARTComponent, debug_to_code, + mark_unclocked, maybe_empty_debug, uart_ns, ) @@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(BLENUS), + cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked), cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of( *[CONF_LOGS, CONF_UART], lower=True ), diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 23dc41d509..56733870dc 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,14 +1,17 @@ +import logging import re from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@mvturnho", "@danielschramm"] DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True @@ -150,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: ) -async def register_canbus(var: MockObj, config: ConfigType) -> None: +async def register_canbus( + var: MockObj, config: ConfigType, *, reports_status: bool | None = None +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) + if reports_status is None: + _LOGGER.warning( + "CAN bus platform '%s' does not report bus state, so bus-off recovery and " + "error-state logging are disabled. Platform authors should either override " + "get_status() and get_events() and call register_canbus(..., reports_status=True) " + "or pass reports_status=False if the hardware cannot report it", + config[CONF_PLATFORM], + ) await setup_canbus_core_(var, config) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 88eed2246f..dfe3ab9fd3 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,5 +1,6 @@ #include "canbus.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::canbus { @@ -65,6 +66,12 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { }; void Canbus::loop() { + uint32_t now = App.get_loop_component_start_time(); + if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { + this->log_events_(this->get_events(), now); + this->last_event_check_time_ = now; + } + struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { @@ -109,4 +116,55 @@ void Canbus::loop() { } } +void Canbus::log_events_(CanEventFlags events, uint32_t now) { + // Bus-off gets its own handling because automatic recovery can make it flap + if (events & CanEventFlags::CAN_EVENT_BUS_OFF) { + this->last_bus_off_time_ = now; + if (!this->bus_off_) { + this->bus_off_ = true; + this->status_set_warning(LOG_STR("Bus off")); + } + } else if (this->bus_off_) { + if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) { + // No bus-off event for the hold-off time, so ask whether it is really over + if (this->get_status().bus_off) { + this->last_bus_off_time_ = now; + } else { + this->bus_off_ = false; + this->status_clear_warning(); + // Events gathered while bus-off say nothing about the recovered bus + this->events_to_log_ = 0; + } + } + } else { + this->events_to_log_ |= events; + if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) { + if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) { + bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE; + if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) { + // Both edges within one throttle window, so the counters decide the current state + auto status = this->get_status(); + error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD || + status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD; + } + ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active")); + } + if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) { + ESP_LOGD(TAG, "receive buffer overrun"); + } + this->events_to_log_ = 0; + this->last_event_log_time_ = now; + } + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) { + auto status = this->get_status(); + ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter, + status.rx_error_counter); + this->last_state_log_time_ = now; + } +#endif +} + } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 432eb77e13..077ac9d4cd 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -48,6 +48,8 @@ template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -61,6 +63,26 @@ struct CanFrame { uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; +enum CanEventFlags : uint8_t { + CAN_EVENT_PASSIVE = 1 << 0, + CAN_EVENT_ACTIVE = 1 << 1, + CAN_EVENT_BUS_OFF = 1 << 2, + CAN_EVENT_RX_QUEUE_FULL = 1 << 3, +}; + +static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX; + +struct CanStatus { + bool bus_off{false}; + uint8_t rx_error_counter{0}; + uint8_t tx_error_counter{0}; + uint32_t tx_failed_count{CAN_STATUS_UNSET}; + uint32_t rx_missed_count{CAN_STATUS_UNSET}; + uint32_t rx_overrun_count{CAN_STATUS_UNSET}; + uint32_t arb_lost_count{CAN_STATUS_UNSET}; + uint32_t bus_error_count{CAN_STATUS_UNSET}; +}; + class Canbus : public Component { public: Canbus(){}; @@ -83,6 +105,7 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } + virtual CanStatus get_status() { return {}; } void add_trigger(CanbusTrigger *trigger); /** @@ -104,12 +127,33 @@ class Canbus : public Component { uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; + uint8_t events_to_log_{0}; + bool bus_off_{false}; CallbackManager &data)> callback_manager_{}; + // interval in which to check for new can bus events (bus-off, passive, active) + static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; + uint32_t last_event_check_time_{0}; + + // time to wait after logging an event before logging the next event(s) + static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000; + uint32_t last_event_log_time_{0}; + + // time span without bus-off events before the bus is declared recovered + static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000; + uint32_t last_bus_off_time_{0}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE + static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000; + uint32_t last_state_log_time_{0}; +#endif + + void log_events_(CanEventFlags events, uint32_t now); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; + virtual CanEventFlags get_events() { return CanEventFlags{}; } }; template class CanbusSendAction final : public Action, public Parented { 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 51a5e01210..78778553aa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -14,9 +14,11 @@ CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" +CONF_BLUE_CHANNEL = "blue_channel" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CLEAR_CHANNEL = "clear_channel" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -29,6 +31,7 @@ CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" +CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_HOLD_STATE = "hold_state" @@ -47,9 +50,11 @@ 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" +CONF_RED_CHANNEL = "red_channel" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 695b7f33d6..4ba93af01f 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,21 +18,40 @@ 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; + static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04) static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000) +static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000) static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001) static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210) -static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) -static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register +static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000) +static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) +static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF) +static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient +// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1 +static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f; + static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation @@ -57,7 +76,18 @@ 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_); + LOG_SENSOR(" ", "Current 2", this->current_sensor_2_); + LOG_SENSOR(" ", "Power 1", this->power_sensor_1_); + LOG_SENSOR(" ", "Power 2", this->power_sensor_2_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_); + LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]); + LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]); } void CSE7761Component::update() { @@ -73,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 { @@ -81,11 +111,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[3] = data & 0xFF; len = 4; } - uint8_t crc = 0; - for (uint32_t i = 0; i < len; i++) { - crc += buffer[i]; - } - buffer[len] = ~crc; + buffer[len] = ~sum_bytes(buffer, len); len++; } @@ -115,46 +141,44 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { } rcvd--; + if (static_cast(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) { + return false; + } uint32_t result = 0; - // CRC check - uint8_t crc = 0xA5 + reg; for (uint32_t i = 0; i < rcvd; i++) { result = (result << 8) | buffer[i]; - crc += buffer[i]; - } - crc = ~crc; - if (crc != buffer[rcvd]) { - return false; } *value = result; return true; } -uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { - bool result = false; // Start loop - uint8_t retry = 3; // Retry up to three times - uint32_t value = 0; // Default no value - while (!result && retry > 0) { - retry--; - if (this->read_once_(reg, size, &value)) - return value; +bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) { + for (uint8_t retry = 0; retry < 3; retry++) { + if (this->read_once_(reg, size, value)) + return true; } ESP_LOGE(TAG, "Reading register %hhu failed!", reg); - return value; + return false; +} + +uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { + uint32_t value = 0; + return this->read_(reg, size, &value) ? value : 0; } 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; @@ -167,21 +191,38 @@ bool CSE7761Component::chip_init_() { calc_chksum += this->data_.coefficient[i]; } calc_chksum = ~calc_chksum; + const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr; uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2); if ((calc_chksum != coeff_chksum) || (!calc_chksum)) { ESP_LOGD(TAG, "Default calibration"); this->data_.coefficient[RMS_IAC] = CSE7761_IREF; this->data_.coefficient[RMS_UC] = CSE7761_UREF; this->data_.coefficient[POWER_PAC] = CSE7761_PREF; + if (energy_configured) { + ESP_LOGW(TAG, "No energy calibration, energy unavailable"); + } + } else if (energy_configured) { + uint32_t hf_const = 0; + 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 * input_scale; + } } this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE); 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); - this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1); + // WaveEN|ZxEN, needed for Ufreq + this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290); } else { ESP_LOGD(TAG, "Write failed at chip_init"); @@ -199,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); @@ -218,12 +260,35 @@ void CSE7761Component::get_data_() { this->voltage_sensor_->publish_state(voltage); } + uint32_t raw_reg = 0; + if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) { + // Ufreq highest bit set means no signal + float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg; + this->frequency_sensor_->publish_state(frequency); + } + + if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) { + // ADC2ON makes the chip report channel A + int24_t pf{static_cast(raw_reg)}; + this->power_factor_sensor_1_->publish_state(static_cast(static_cast(pf)) / 0x800000); + } + for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 float active_power = static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); + sensor::Sensor *energy_sensor = this->energy_sensors_[channel]; + CSE7761EnergyChannel &energy = this->energy_[channel]; + uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB; + uint32_t raw = 0; + if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) { + // 24-bit counter, not cleared on read + energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF; + energy.last_pulses = raw; + energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse); + } if (channel == 0) { if (this->power_sensor_1_ != nullptr) { this->power_sensor_1_->publish_state(active_power); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index e08ebf09cc..fc4eac6477 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -3,11 +3,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" +#include "esphome/core/datatypes.h" namespace esphome::cse7761 { struct CSE7761DataStruct { - uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; int32_t active_power[2] = {0}; @@ -15,14 +15,32 @@ struct CSE7761DataStruct { bool ready = false; }; +struct CSE7761EnergyChannel { + uint32_t last_pulses{0}; + uint32_t pulses{0}; + 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) { + power_factor_sensor_1_ = power_factor_sensor_1; + } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; } void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; } void setup() override; void dump_config() override; void update() override; @@ -30,14 +48,23 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice protected: // Sensors sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_1_{nullptr}; sensor::Sensor *power_sensor_1_{nullptr}; sensor::Sensor *current_sensor_1_{nullptr}; sensor::Sensor *power_sensor_2_{nullptr}; sensor::Sensor *current_sensor_2_{nullptr}; + 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); uint32_t read_(uint8_t reg, uint8_t size); uint32_t coefficient_by_unit_(uint32_t unit); bool chip_init_(); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 5f79be0255..6f695307de 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -2,15 +2,22 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( + CONF_FREQUENCY, CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HERTZ, UNIT_VOLT, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -26,17 +33,41 @@ CONF_CURRENT_1 = "current_1" CONF_CURRENT_2 = "current_2" CONF_ACTIVE_POWER_1 = "active_power_1" 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, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, @@ -61,6 +92,18 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -79,19 +122,17 @@ 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) - for key in [ - CONF_VOLTAGE, - CONF_CURRENT_1, - CONF_CURRENT_2, - CONF_ACTIVE_POWER_1, - CONF_ACTIVE_POWER_2, - ]: - if key not in config: - continue - conf = config[key] - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor) + await sensors(CONF_CURRENT_1, var.set_current_1_sensor) + await sensors(CONF_CURRENT_2, var.set_current_2_sensor) + await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor) + await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor) + await sensors(CONF_ENERGY_1, var.set_energy_1_sensor) + await sensors(CONF_ENERGY_2, var.set_energy_2_sensor) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 9276901156..bbe76855fb 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() { } if (index == CSE7766_RAW_DATA_SIZE - 1) { - uint8_t checksum = 0; - for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { - checksum += this->raw_data_[i]; - } + const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3); if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, @@ -205,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/daikin.cpp b/esphome/components/daikin/daikin.cpp index 8b8d16677a..370c04fbf6 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,6 +1,7 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::daikin { @@ -8,14 +9,6 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate"); static constexpr uint32_t BOOST_TIMEOUT_ID = 0; -static uint8_t sum_bytes(const uint8_t *data, size_t len) { - uint8_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void DaikinClimate::setup() { climate_ir::ClimateIR::setup(); // A boost preset restored from flash gets its timer too diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e79135b185..fa01df73d8 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() { } bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGI(TAG, "checksum error"); return false; 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 5d8ef52dc0..c16eea733f 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,5 +1,6 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::daikin_brc { @@ -19,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]; } @@ -160,10 +166,7 @@ uint8_t DaikinBrcClimate::temperature_() { } bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum); return false; @@ -256,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/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index a486037266..04ef798b69 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector data) { while ((it = std::find(it, data.end(), 0xA5)) != data.end()) { if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) { - uint8_t checksum; - int sum = 0; - for (int i = 0; i < 12; i++) { - sum += it[i]; - } - checksum = sum; + const uint8_t checksum = sum_bytes(&*it, 12); if (checksum == it[12]) { switch (it[2]) { diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index eec4d7f55b..3ed794daea 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,5 +1,6 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::delonghi { @@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() { } bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1); if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) { return false; } diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 1cd75ddaf0..0b871af30c 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_PAGES, CONF_ROTATION, CONF_TO, - CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, CONF_WIDTH, SCHEDULER_DONT_RUN, @@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayOnPageChangeTrigger = display_ns.class_( - "DisplayOnPageChangeTrigger", automation.Trigger -) CONF_ON_PAGE_CHANGE = "on_page_change" CONF_SHOW_TEST_CARD = "show_test_card" CONF_UNSPECIFIED = "unspecified" +_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT") + DISPLAY_ROTATIONS = { 0: display_ns.DISPLAY_ROTATION_0_DEGREES, 90: display_ns.DISPLAY_ROTATION_90_DEGREES, @@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( ), cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayOnPageChangeTrigger - ), cv.Optional(CONF_FROM): cv.use_id(DisplayPage), cv.Optional(CONF_TO): cv.use_id(DisplayPage), } @@ -137,15 +132,18 @@ async def setup_display_core_(var, config): pages.append(page) cg.add(var.set_pages(pages)) for conf in config.get(CONF_ON_PAGE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_FROM in conf: - page = await cg.get_variable(conf[CONF_FROM]) - cg.add(trigger.set_from(page)) - if CONF_TO in conf: - page = await cg.get_variable(conf[CONF_TO]) - cg.add(trigger.set_to(page)) - await automation.build_automation( - trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf + _request_page_change_slot(str(var)) + checks = [] + for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")): + if (page_id := conf.get(key)) is not None: + page = await cg.get_variable(page_id) + checks.append(f"{name} == ::{page}") + await automation.build_callback_automation( + var, + "add_on_page_change_callback", + [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], + conf, + when=" && ".join(checks) or None, ) if config.get(CONF_SHOW_TEST_CARD): cg.add(var.show_test_card()) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 49cd2fc7c6..0bd8346953 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -678,12 +678,14 @@ void Display::set_pages(std::vector pages) { } void Display::show_page(DisplayPage *page) { - this->previous_page_ = this->page_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + DisplayPage *previous = this->page_; this->page_ = page; - if (this->previous_page_ != this->page_) { - for (auto *t : on_page_change_triggers_) - t->process(this->previous_page_, this->page_); - } + if (previous != page) + this->page_change_callback_.call(previous, page); +#else + this->page_ = page; +#endif } void Display::do_update_() { @@ -700,11 +702,6 @@ void Display::do_update_() { this->clear_clipping_(); } -void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { - if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) - this->trigger(from, to); -} - void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 74b5bcedd9..7d1053f4fa 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -7,6 +7,8 @@ #include "esphome/core/color.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/core/log.h" #include "display_color_utils.h" @@ -173,7 +175,6 @@ enum RegularPolygonDrawing { class Display; class DisplayPage; -class DisplayOnPageChangeTrigger; /** Optimized display writer that uses function pointers for stateless lambdas. * @@ -702,7 +703,11 @@ class Display : public PollingComponent { const DisplayPage *get_active_page() const { return this->page_; } - void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + template void add_on_page_change_callback(F &&callback) { + this->page_change_callback_.add(std::forward(callback)); + } +#endif /// Internal method to set the display rotation with. virtual void set_rotation(DisplayRotation rotation); @@ -809,8 +814,9 @@ class Display : public PollingComponent { DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES}; display_writer_t writer_{}; DisplayPage *page_{nullptr}; - DisplayPage *previous_page_{nullptr}; - std::vector on_page_change_triggers_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + StaticCallbackManager page_change_callback_; +#endif bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; uint8_t wdt_pixel_counter_{0}; @@ -850,18 +856,6 @@ template class DisplayPageShowAction final : public Action { - public: - explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } - void process(DisplayPage *from, DisplayPage *to); - void set_from(DisplayPage *p) { this->from_ = p; } - void set_to(DisplayPage *p) { this->to_ = p; } - - protected: - DisplayPage *from_{nullptr}; - DisplayPage *to_{nullptr}; -}; - const LogString *text_align_to_string(TextAlign textalign); } // namespace esphome::display 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/emontx/__init__.py b/esphome/components/emontx/__init__.py index 971f976924..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" @@ -44,20 +45,11 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] -def _synchronous_automation(value: ConfigType) -> ConfigType: - """Reject deferring actions: the received line and parsed JSON are only valid while the - automation runs, and DelayAction and friends keep the trigger arguments for later.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " - "on_data: the received data is only valid while the automation runs. Copy what you need " - "into globals first, then defer in a separate script or automation." - ) - return value - - def _synchronous_automation_schema() -> cv.All: - return cv.All(automation.validate_automation({}), _synchronous_automation) + # The received line and the parsed JSON document only live while the callbacks run. + return automation.validate_synchronous_automation( + "on_json and on_data", "the received data" + ) # Main configuration schema @@ -67,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) @@ -142,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 @@ -166,3 +162,16 @@ automation.register_apply_action( CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal ), ) + + +# Let another component (e.g. serial_proxy) take exclusive ownership of the UART. + +EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)}) + +automation.register_apply_action( + "emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)") +) +automation.register_apply_action( + "emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)") +) +automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()") diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 675744b5f5..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. @@ -16,6 +36,20 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } * 2. If line starts with '{', parse as JSON and update sensors/callbacks */ void EmonTx::loop() { + if (this->is_paused()) { + this->was_paused_ = true; + return; + } + if (this->was_paused_) { + // Drop what arrived during the pause and resync at the next line start + this->was_paused_ = false; + this->buffer_pos_ = 0; + this->discarding_line_ = true; + uint8_t dropped; + while (this->available() > 0 && this->read_byte(&dropped)) + this->discarding_line_ = dropped != '\n'; + } + // Read all available data to prevent UART buffer overflow while (this->available() > 0) { uint8_t received; @@ -45,7 +79,7 @@ void EmonTx::loop() { } } } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed the buffer + // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; @@ -83,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()); @@ -100,6 +135,10 @@ void EmonTx::dump_config() { * @param command The command string to send (LF will be appended automatically). */ void EmonTx::send_command(const char *command) { + if (this->is_paused()) { + ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command); + return; + } ESP_LOGD(TAG, "Sending command to emonTx: %s", command); this->write_str(command); this->write_byte('\n'); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index f97c5aa6cb..bcec7ce171 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -8,6 +8,7 @@ #include "esphome/components/json/json_util.h" #include +#include #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" @@ -32,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)); } @@ -41,6 +43,16 @@ class EmonTx final : public Component, public uart::UARTDevice { void send_command(const char *command); void send_command(const std::string &command) { this->send_command(command.c_str()); } + /// Stop reading the UART and sending commands, so another component can use it. + /// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass. + void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); } + + 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); @@ -55,7 +67,10 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; - bool discarding_line_{false}; // Set after an overflow until the end of that line + 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_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 75a099a3c4..d37b2f4020 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,4 +1,6 @@ import encodings +import struct +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,8 +27,10 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigType, TemplateArgsType AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] @@ -111,8 +115,6 @@ BLECharacteristicNotifyAction = esp32_ble_server_automations_ns.class_( ) bytebuffer_ns = cg.esphome_ns.namespace("bytebuffer") Endianness_ns = bytebuffer_ns.namespace("Endian") -ByteBuffer_ns = bytebuffer_ns.namespace("ByteBuffer") -ByteBuffer = bytebuffer_ns.class_("ByteBuffer") PROPERTY_MAP = { @@ -141,6 +143,15 @@ class ValueType: return value +def float32(value: Any) -> float: + value = cv.float_(value) + try: + struct.pack(" list[int]: + """The bytes of a constant value, as ByteBuffer::wrap packed them at runtime.""" + value = value_config[CONF_DATA] + if isinstance(value, str): + return list(value.encode(value_config[CONF_STRING_ENCODING])) + if isinstance(value, list): + return value + order = ">" if value_config[CONF_ENDIANNESS] == "BIG" else "<" + return list(struct.pack(order + _STRUCT_FORMATS[value_config[CONF_TYPE]], value)) + + async def parse_value(value_config, args): value = value_config[CONF_DATA] if isinstance(value, cv.Lambda): return await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + # An initializer list calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value_bytes(value_config)) - if isinstance(value, str): - value = list(value.encode(value_config[CONF_STRING_ENCODING])) - if isinstance(value, list): - # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) - # This calls the set_value(std::initializer_list) overload - return cg.ArrayInitializer(*value) - val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") - return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) + +async def set_value_buffer( + var: MockObj, value_config: ConfigType, args: TemplateArgsType +) -> None: + value = value_config[CONF_DATA] + if not isinstance(value, cv.Lambda): + value = value_bytes(value_config) + await automation.templatable_bytes( + value, args, var.set_buffer_template, var.set_buffer_static, "ble_server_value" + ) def calculate_num_handles(service_config): @@ -662,8 +702,7 @@ async def to_code(config): async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -682,8 +721,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 75a3399379..1571a6077b 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -30,6 +30,14 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } +void BLECharacteristic::set_value(const uint8_t *data, size_t length) { + if (length == 0) { // An empty constant is a nullptr table + this->value_.clear(); + return; + } + this->value_.assign(data, data + length); +} + void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index b7a3fae1a5..905f5b7a0d 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -30,6 +30,7 @@ class BLECharacteristic final { ~BLECharacteristic(); void set_value(ByteBuffer buffer); + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(const std::string &buffer); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 228313e2d8..7810dba9f5 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -49,17 +49,18 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value(buffer.data(), buffer.size()); } -void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value(data.begin(), data.size()); } -void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { +void BLEDescriptor::set_value(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, data, length); + if (length != 0) // An empty constant is a nullptr table + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5096d39f28..1ffd403434 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -26,6 +26,7 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } @@ -42,8 +43,6 @@ class BLEDescriptor { } protected: - void set_value_impl_(const uint8_t *data, size_t length); - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index e5463847fa..d90b2a28fd 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -16,6 +16,16 @@ namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; +/// Sets a characteristic or descriptor value from a constant flash table or a lambda's vector. +// Not TemplatableBytes::visit, which would copy the lambda's vector instead of moving it into the value. +template void apply_value(T *target, const V &value, Xs &&...x) { + if (value.is_static()) { + target->set_value(value.data(), value.size()); + } else { + target->set_value(value.value(x...)); + } +} + class BLETriggers { public: #ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -67,25 +77,23 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } + TEMPLATABLE_BYTES(buffer) void play(const Ts &...x) override { // If the listener is already set, do nothing if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); // Set the listener for read events // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference // (e.g. climate on_control's ClimateCall&). See #17142. this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { apply_value(this->parent_, this->buffer_, x...); }); } protected: @@ -115,10 +123,8 @@ template class BLECharacteristicNotifyAction final : public Acti template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } - void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } + TEMPLATABLE_BYTES(buffer) + void play(const Ts &...x) override { apply_value(this->parent_, this->buffer_, x...); } protected: BLEDescriptor *parent_; diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2272459fb8..2713bae241 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -129,7 +129,7 @@ async def to_code(config: ConfigType) -> None: # Also enable esp_driver_twai for future migration to new API include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index 083c97f8cf..b98ba853e7 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -84,6 +84,8 @@ bool ESP32Can::setup_internal() { twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; + g_config.alerts_enabled = TWAI_ALERT_ERR_PASS | TWAI_ALERT_ERR_ACTIVE | TWAI_ALERT_BUS_OFF | + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_RX_QUEUE_FULL; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); } @@ -116,6 +118,86 @@ bool ESP32Can::setup_internal() { return true; } +canbus::CanEventFlags ESP32Can::get_events() { + if (this->twai_handle_ == nullptr) { + return {}; // not set up yet, or setup failed + } + + uint32_t alerts; + esp_err_t err = twai_read_alerts_v2(this->twai_handle_, &alerts, 0); + if (err != ESP_OK) { + if (err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "failed to get CAN events"); + } + alerts = 0; + } + + uint8_t events = 0; + if (alerts & TWAI_ALERT_ERR_PASS) { + events |= canbus::CAN_EVENT_PASSIVE; + } + if (alerts & TWAI_ALERT_ERR_ACTIVE) { + events |= canbus::CAN_EVENT_ACTIVE; + } + if (alerts & TWAI_ALERT_BUS_OFF) { + events |= canbus::CAN_EVENT_BUS_OFF; + // Start recovery at once, as the MCP2515 does in hardware + this->recovery_pending_ = true; + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + // Recovery leaves the controller stopped + this->restart_pending_ = true; + } + if (this->recovery_pending_ || this->restart_pending_) { + this->handle_bus_off_recovery_(); + } + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + } + return static_cast(events); +} + +void ESP32Can::handle_bus_off_recovery_() { + // These only fail with an invalid handle or outside the expected state; retry on the next pass + if (this->recovery_pending_) { + if (twai_initiate_recovery_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Recovery after bus off failed")); + return; + } + this->recovery_pending_ = false; + } + if (this->restart_pending_) { + if (twai_start_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Restart after bus off failed")); + return; + } + this->restart_pending_ = false; + } + this->status_clear_error(); +} + +canbus::CanStatus ESP32Can::get_status() { + canbus::CanStatus status = {}; + + twai_status_info_t twai_status; + + if (this->twai_handle_ != nullptr && twai_get_status_info_v2(this->twai_handle_, &twai_status) == ESP_OK) { + status.bus_off = (twai_status.state == TWAI_STATE_BUS_OFF) || (twai_status.state == TWAI_STATE_RECOVERING); + status.rx_error_counter = twai_status.rx_error_counter; + status.tx_error_counter = twai_status.tx_error_counter; + + status.tx_failed_count = twai_status.tx_failed_count; + status.rx_missed_count = twai_status.rx_missed_count; + status.rx_overrun_count = twai_status.rx_overrun_count; + status.arb_lost_count = twai_status.arb_lost_count; + status.bus_error_count = twai_status.bus_error_count; + } else { + ESP_LOGD(TAG, "failed to get CAN status"); + } + + return status; +} + canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { // In listen-only mode, we cannot transmit if (this->mode_ == CAN_MODE_LISTEN_ONLY) { diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f224e10be3..f9549bea34 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -24,16 +24,21 @@ class ESP32Can final : public canbus::Canbus { void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { this->tx_enqueue_timeout_ticks_ = pdMS_TO_TICKS(tx_enqueue_timeout_ms); } + canbus::CanStatus get_status() override; ESP32Can(){}; protected: bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error send_message(struct canbus::CanFrame *frame) override; canbus::Error read_message(struct canbus::CanFrame *frame) override; + void handle_bus_off_recovery_(); int rx_{-1}; int tx_{-1}; CanMode mode_{CAN_MODE_NORMAL}; + bool recovery_pending_{false}; + bool restart_pending_{false}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; 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/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/event/__init__.py b/esphome/components/event/__init__.py index c40130634f..b09d979141 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -98,7 +98,9 @@ async def setup_event_core_( ) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - cg.add(var.set_event_types(event_types)) + if event_types: + table = cg.shared_progmem_array("event_types", cg.const_char_ptr, event_types) + cg.add(var.set_event_types_static(table, len(event_types))) setup_device_class(config) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 3170c7a8dd..671e11e7d2 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -28,11 +28,8 @@ void Event::trigger(const char *event_type) { #endif } -void Event::set_event_types(const FixedVector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } +void Event::set_event_types_copy_(const char *const *event_types, size_t count) { + this->types_.assign_copy(event_types, count); this->last_event_type_ = nullptr; // Reset when types change } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f57327e33f..203ee52ac8 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -11,6 +11,9 @@ namespace esphome::event { +/// A codegen table in flash, or a heap copy of a runtime list (see ConstVector). +using EventTypes = ConstVector; + #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ @@ -24,20 +27,29 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event; called by generated code with string literals. + /// Codegen only: points at a table that outlives the event (see ConstVector::assign_static). + void set_event_types_static(const char *const *event_types, size_t count) { + this->types_.assign_static(event_types, count); + this->last_event_type_ = nullptr; + } + /// Runtime lists are copied; the strings must still outlive the event. void set_event_types(std::initializer_list event_types) { - this->types_ = event_types; - this->last_event_type_ = nullptr; // Reset when types change + this->set_event_types_copy_(event_types.begin(), event_types.size()); } /// Copy the event types of another event, for components that wrap one. - void set_event_types(const FixedVector &event_types); + void set_event_types(const EventTypes &event_types) { + this->set_event_types_copy_(event_types.data(), event_types.size()); + } + void set_event_types(const FixedVector &event_types) { + this->set_event_types_copy_(event_types.begin(), event_types.size()); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; /// Return the event types supported by this event. - const FixedVector &get_event_types() const { return this->types_; } + const EventTypes &get_event_types() const { return this->types_; } /// Return the last triggered event type, or empty StringRef if no event triggered yet. StringRef get_last_event_type() const { return StringRef::from_maybe_nullptr(this->last_event_type_); } @@ -68,8 +80,10 @@ class Event : public EntityBase { } protected: + void set_event_types_copy_(const char *const *event_types, size_t count); + LazyCallbackManager event_callback_; - FixedVector types_; + EventTypes types_; private: /// Last triggered event type - must point to entry in types_ to ensure valid lifetime. diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 547b90d862..fd87edc005 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -65,9 +65,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_( @@ -116,11 +113,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), @@ -231,6 +224,17 @@ def set_preset_modes(var: MockObj, preset_modes: list[str]) -> None: cg.add(var.set_supported_preset_modes_static(table, len(preset_modes))) +# 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])) @@ -276,26 +280,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/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index 81d6647642..b5e1c98bcb 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -42,12 +42,7 @@ void FS3000Component::update() { } // checksum passes if the modulo 256 sum of the five bytes is 0 - uint8_t checksum = 0; - for (uint8_t i : data) { - checksum += i; - } - - if (checksum != 0) { + if (sum_bytes(data, sizeof(data)) != 0) { this->status_set_warning(); ESP_LOGW(TAG, "Checksum failure when reading from FS3000"); return; diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9a928dac01..af411d133a 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -224,11 +224,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { } uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { - uint8_t checksum = 0; - for (uint8_t i = 7; i < FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 1; ++i) { - checksum += message[i]; - } - return 256 - checksum; + return -sum_bytes(message + 7, FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 8); } uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index f47be4aea0..0e02f20080 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c261629573..a85878e764 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6..b48d32b900 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index ba76053686..db915e9c7f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -186,7 +186,12 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), + # data points into the protocol's receive buffer, reused for the next frame + cv.Optional( + CONF_ON_STATUS_MESSAGE + ): automation.validate_synchronous_automation( + "on_status_message", "the received frame" + ), } ) .extend(uart.UART_DEVICE_SCHEMA) 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/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index bfed99b21b..ddfe77d3ac 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -80,14 +80,7 @@ void HM3301Component::update() { this->status_clear_warning(); } -bool HM3301Component::validate_checksum_(const uint8_t *data) { - uint8_t sum = 0; - for (int i = 0; i < 28; i++) { - sum += data[i]; - } - - return sum == data[28]; -} +bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index a2c181a5ae..89fe5f8b1c 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -39,6 +39,7 @@ HttpRequestIDF = http_request_ns.class_("HttpRequestIDF", HttpRequestComponent) HttpRequestHost = http_request_ns.class_("HttpRequestHost", HttpRequestComponent) HttpContainer = http_request_ns.class_("HttpContainer") +RequestHeader = http_request_ns.struct("RequestHeader") HttpRequestSendAction = http_request_ns.class_( "HttpRequestSendAction", automation.Action @@ -360,12 +361,27 @@ async def http_request_action_to_code( for key in json_: template_ = await cg.templatable(json_[key], args, cg.std_string) cg.add(var.add_json(key, template_)) - request_headers = config.get(CONF_REQUEST_HEADERS, {}) - if request_headers: - cg.add(var.init_request_headers(len(request_headers))) - for key, value in request_headers.items(): - template_ = await cg.templatable(value, args, cg.const_char_ptr) - cg.add(var.add_request_header(key, template_)) + if request_headers := config.get(CONF_REQUEST_HEADERS): + # A user lambda may keep static state, so only all-constant lists are shared + share = not any(map(cg.is_template, request_headers.values())) + # Constant headers ignore their arguments, so drop the names: identical lists + # from scripts with different parameter names then render the same table. + header_args = [(arg_type, "") for arg_type, _ in args] if share else args + headers = [ + cg.StructInitializer( + "", + ("name", key), + ("value", await cg.templatable(value, header_args, cg.const_char_ptr)), + ) + for key, value in request_headers.items() + ] + table = cg.shared_progmem_array( + "http_request_headers", + RequestHeader.template(*[arg_type for arg_type, _ in args]), + headers, + share=share, + ) + cg.add(var.set_request_headers(table, len(headers))) for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value.lower())) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 68696118ea..424fcf84e8 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -22,6 +22,12 @@ struct Header { std::string value; }; +/// One request header of an action; codegen puts these in a shared flash table. +template struct RequestHeader { + const char *name; + const char *(*value)(Ts...); +}; + // Some common HTTP status codes enum HttpStatus { HTTP_STATUS_OK = 200, @@ -492,9 +498,12 @@ template class HttpRequestSendAction final : public Actionrequest_headers_.init(count); } - void add_request_header(const char *key, TemplatableFn value) { - this->request_headers_.push_back({key, value}); + static_assert(std::is_trivially_copyable_v> && + sizeof(RequestHeader) % sizeof(uint32_t) == 0, + "ESP8266 reads request headers from flash in whole words"); + /// Codegen only: the table must outlive the action. + void set_request_headers(const RequestHeader *headers, size_t count) { + this->request_headers_ = ConstVector>(headers, count); } void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } @@ -530,8 +539,8 @@ template class HttpRequestSendAction final : public Action request_headers; request_headers.reserve(this->request_headers_.size()); - for (const auto &[key, val] : this->request_headers_) { - request_headers.push_back({key, val.value(x...)}); + for (const auto &header : this->request_headers_) { + request_headers.push_back({header.name, header.value(x...)}); } auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, @@ -596,7 +605,7 @@ template class HttpRequestSendAction final : public Action>> request_headers_{}; + ConstVector> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; 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/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 1045467866..906781e17b 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -95,16 +95,9 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; - // Calculate checksum for message - uint8_t checksum = 0; - for (uint8_t i = 0; i < header_checksum_len; i++) { - checksum += this->tx_header_[i]; - } + uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len); if (there_is_data) { - // Include data in checksum - for (size_t i = 0; i < size; i++) { - checksum += data[i]; - } + checksum = sum_bytes(data, size, checksum); } this->tx_header_[TX_CHECKSUM_IDX] = checksum; 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/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/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/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 66aaac5301..9b2f41e063 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) from esphome.components.image import ( CONF_OPAQUE, @@ -158,7 +159,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" -LVGL_VERSION = "9.5.0" +LVGL_VERSION = "9.6.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -324,10 +325,16 @@ async def to_code(configs): # Skip compiling lvgl examples add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False) add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False) + # Match LV_USE_STDLIB_MALLOC in lv_conf.h. This also drops the builtin malloc + # options, which LVGL 9.5 builds leave set to deprecated values. + add_idf_sdkconfig_option("CONFIG_LV_USE_CUSTOM_MALLOC", True) if get_esp32_variant() == VARIANT_ESP32P4: add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) - # disable use of PPA for fills until upstream bugs fixed - df.add_define("LV_USE_PPA", "0") + # The LVGL PPA driver uses cache functions added in ESP-IDF 6.0 + if idf_version() >= cv.Version(6, 0, 0): + df.add_define("LV_USE_PPA", "1") + else: + df.add_define("LV_USE_PPA", "0") df.add_define("LV_DRAW_BUF_ALIGN", "64") else: df.add_define("LV_DRAW_BUF_ALIGN", "32") @@ -337,6 +344,8 @@ async def to_code(configs): cg.add_library("lvgl/lvgl", LVGL_VERSION) df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") df.add_define("LV_USE_DRAW_SW", "1") + # LVGL 9.6 rejects 0 here unless tiled rendering is disabled + df.add_define("LV_DRAW_SW_DRAW_UNIT_CNT", "1") df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") @@ -356,15 +365,22 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + # Big-endian displays get LVGL to render directly in swapped byte order + display_format = ( + "RGB565_SWAPPED" if config_0[CONF_BYTE_ORDER] == BYTE_ORDER_BIG else "RGB565" + ) + df.add_define("LV_COLOR_FORMAT_DEFAULT", f"LV_COLOR_FORMAT_{display_format}") for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") - - if config_0[CONF_COLOR_DEPTH] == 16: + df.add_define("LV_OBJ_STYLE_CACHE", "1") + if any(config[df.CONF_CHECK_ARGS] for config in configs): + df.add_define("LV_USE_CHECK_ARG", "1") df.add_define( - "LV_COLOR_16_SWAP", - "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", + "LV_CHECK_ARG_LOG_MODE", + f"LV_CHECK_ARG_LOG_MODE_{df.LV_CHECK_ARG_LOG_MODES[config_0[CONF_LOG_LEVEL]]}", ) + else: + df.add_define("LV_USE_CHECK_ARG", "0") df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), @@ -504,8 +520,9 @@ async def to_code(configs): if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + # The display buffer format is always needed, RGB565 is used by canvas buffers and + # ARGB8888 for layer blending + lv_image_formats = {"RGB565", display_format, "ARGB8888"} for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -519,7 +536,7 @@ async def to_code(configs): if image_type == ImageRGB565: lv_image_formats.add("RGB565A8" if transparent else "RGB565") if image_type == ImageRGB: - lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + lv_image_formats.add("ARGB8888" if transparent else "RGB888") if df.is_defined("LV_GRADIENT_MAX_STOPS"): lv_image_formats.add("RGB888") for fmt in lv_image_formats: @@ -595,6 +612,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), + cv.Optional(df.CONF_CHECK_ARGS, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), 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/automation.py b/esphome/components/lvgl/automation.py index d797688a7e..4e8698f33c 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, CONF_GROUP, + CONF_HIDDEN, CONF_ID, CONF_POSITION, CONF_ROTATION, @@ -354,7 +355,7 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): - widget.add_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, True) widgets = [widget.outer or widget for widget in await get_widgets(config)] return await action_to_code(widgets, do_hide, action_id, template_arg, args) @@ -365,7 +366,7 @@ async def obj_hide_to_code(config, action_id, template_arg, args): ) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): - widget.clear_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, False) if widget.move_to_foreground: lv_obj.move_foreground(widget.obj) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index fc7317f8b9..e658735c75 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -6,7 +6,7 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..defines import CONF_CLICKABLE, CONF_WIDGET, LvConstant from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets @@ -41,7 +41,7 @@ async def to_code(config): ctx.add(sensor.publish_initial_state(test_expr)) if is_pressed: events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] - widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + widget.set_flag(CONF_CLICKABLE, True) else: events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( 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 c019dff96e..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, {}) @@ -477,6 +477,16 @@ LV_LOG_LEVELS = { "NONE": "NONE", } +# Log output for failed LVGL argument checks, chosen from the LVGL log level +LV_CHECK_ARG_LOG_MODES = { + "VERBOSE": "VERBOSE", + "DEBUG": "VERBOSE", + "INFO": "MINIMAL", + "WARN": "MINIMAL", + "ERROR": "NONE", + "NONE": "NONE", +} + LV_LONG_MODES = LvConstant( "LV_LABEL_LONG_", "WRAP", @@ -606,13 +616,20 @@ OBJ_FLAGS = ( "ignore_layout", "floating", "overflow_visible", + "send_draw_task_events", + "event_trickle", + "state_trickle", + "radio_button", + "flex_in_new_track", "layout_1", "layout_2", - "send_draw_task_events", "widget_1", "widget_2", ) -LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) +# Flags whose lv_obj_set_() setter has a different name +FLAG_SETTER_ALIASES = {"layout_1": "flex_in_new_track"} +# Custom flags that have no lv_obj_set_() setter +CUSTOM_FLAGS = ("layout_2", "widget_1", "widget_2") ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") @@ -703,8 +720,10 @@ CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" 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" @@ -727,6 +746,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_CHECK_ARGS = "check_args" CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" @@ -769,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" @@ -795,6 +814,7 @@ CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" @@ -891,7 +911,8 @@ def get_part_state_selector(part: str, state: str) -> MockObj: # fmt: off LV_COLOR_FORMATS = ( - "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", + "RGB565", "RGB565_SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "ARGB8888_PREMULTIPLIED", "L8", "AL88", + "A8", "I1", ) LV_DEFINES = ( 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/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index e35cc0c48b..0b375404d7 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -6,6 +6,7 @@ #include "core/lv_global.h" #include "core/lv_obj_class_private.h" +#include "core/lv_obj_private.h" #include @@ -225,6 +226,36 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev add_event_cb(obj, callback, event3, user_data); } +void LvglComponent::lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value) { + // LVGL 9.6 keeps these flags in spec_attr->user_flags, in the same bits as its deprecated lv_obj_add_flag() + uint8_t mask; + switch (flag) { + case LV_OBJ_FLAG_LAYOUT_2: + mask = 1 << 1; + break; + case LV_OBJ_FLAG_WIDGET_1: + mask = 1 << 2; + break; + case LV_OBJ_FLAG_WIDGET_2: + mask = 1 << 3; + break; + default: + return; + } + if (value) { + lv_obj_spec_attr_t *spec_attr = lv_obj_allocate_spec_attr(obj); + if (spec_attr == nullptr) + return; + spec_attr->user_flags |= mask; + } else if (obj->spec_attr != nullptr) { + obj->spec_attr->user_flags &= ~mask; + } + if (flag == LV_OBJ_FLAG_LAYOUT_2) { + lv_obj_mark_layout_as_dirty(lv_obj_get_parent(obj)); + lv_obj_mark_layout_as_dirty(obj); + } +} + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); @@ -822,7 +853,7 @@ void LvglComponent::setup() { } this->draw_buf_ = static_cast(buffer); this->set_resolution_(); - lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_DEFAULT); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); @@ -994,7 +1025,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_clickable(obj, false); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8b7397c4cd..4da8a6a1a3 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -269,6 +269,9 @@ class LvglComponent final : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3, void *user_data = nullptr); + // Set or clear LV_OBJ_FLAG_LAYOUT_2, LV_OBJ_FLAG_WIDGET_1 or LV_OBJ_FLAG_WIDGET_2, which have no setter in LVGL 9.6 + static void lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value); + // change the state of a widget and fire an event if changed (only needed for CHECKED) static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 924c17fb54..2d879e8d43 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -391,7 +391,6 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) VALUE_TRIGGER_SCHEMA = { cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( 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/trigger.py b/esphome/components/lvgl/trigger.py index 56dcf81a79..cd118595f1 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -17,6 +17,8 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_CLICKABLE, + CONF_SCROLLABLE, DIRECTIONS, LV_DISPLAY_EVENT_MAP, LV_DISPLAY_EVENT_TRIGGERS, @@ -72,7 +74,7 @@ async def generate_triggers(): event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger(conf, w, event) for event, conf in { @@ -82,7 +84,7 @@ async def generate_triggers(): dir = event[9:].upper() dir = {"UP": "TOP", "DOWN": "BOTTOM"}.get(dir, dir) dir = DIRECTIONS.mapper(dir) - w.clear_flag("LV_OBJ_FLAG_SCROLLABLE") + w.set_flag(CONF_SCROLLABLE, False) selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d69e90056f..210314103e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import ExitStack import sys from typing import Any @@ -37,23 +38,25 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + CUSTOM_FLAGS, + FLAG_SETTER_ALIASES, LOGGER, OBJ_FLAGS, PARTS, STATES, LValidator, add_lv_use, + add_warning, get_options, get_part_state_selector, get_styles_used, get_widget_map, - join_enums, literal, next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( - LvConditional, + 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: @@ -295,7 +330,9 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - def __init__(self, var, wtype: WidgetType, config: dict = None): + def __init__( + self, var: MockObj | ID, wtype: WidgetType, config: dict | None = None + ) -> None: self.var = var self.type = wtype self.config = config @@ -314,39 +351,50 @@ class Widget: self.parent = None @staticmethod - def create(name, var, wtype: WidgetType, config: dict = None): + def create( + name: ID | str, var: MockObj, wtype: WidgetType, config: dict | None = None + ) -> "Widget": w = Widget(var, wtype, config) get_widget_map()[name] = w return w - def set_state(self, state: MockObj, value: bool | Expression): + def set_state(self, state: MockObj, value: bool | Expression) -> None: lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def has_state(self, state: MockObj): + def has_state(self, state: MockObj) -> MockObj: return lv_expr.obj_has_state(self.obj, state) - def is_pressed(self): + def is_pressed(self) -> MockObj: return self.has_state(LV_STATE.PRESSED) - def is_checked(self): + def is_checked(self) -> MockObj: return self.has_state(LV_STATE.CHECKED) - def add_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.add_flag(self.obj, literal(flag)) + def set_flag(self, flag: str, value: bool | Expression) -> None: + """ + Set or clear an object flag, given as the lower case name without the LV_OBJ_FLAG_ prefix + """ + flag = FLAG_SETTER_ALIASES.get(flag, flag) + if flag in CUSTOM_FLAGS: + lv_add( + lvgl_static.lv_obj_set_custom_flag( + self.obj, literal(f"LV_OBJ_FLAG_{flag.upper()}"), value + ) + ) + else: + lv_obj.call(f"set_{flag}", self.obj, value) - def clear_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.remove_flag(self.obj, literal(flag)) - - def add_style(self, style_id, state=LV_STATE.DEFAULT): + def add_style(self, style_id: ID, state: MockObj | str = LV_STATE.DEFAULT) -> None: lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( - self, prop, value, animated: bool = None, lv_name=None, processor=None - ): + self, + prop: str, + value: Any, + animated: bool | None = None, + lv_name: str | None = None, + processor: LValidator | Callable[[Any], Awaitable[Any]] | None = None, + ) -> None: """ Set a property of the widget. :param prop: The property name @@ -387,11 +435,13 @@ class Widget: literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) - def get_property(self, prop, ltype=None): + def get_property(self, prop: str, ltype: str | None = None) -> cg.RawExpression: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): + def set_style( + self, prop: str, value: Any, state: MockObj | str = LV_STATE.DEFAULT + ) -> None: if value is None: return get_styles_used().add(prop) @@ -399,22 +449,22 @@ class Widget: value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) - def __type_base(self): + def __type_base(self) -> str: wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") - def __str__(self): + def __str__(self) -> str: return f"({self.var}, {self.type})" - def get_args(self): + def get_args(self) -> list[tuple[MockObj, str]]: if isinstance(self.type.w_type, LvType): return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] - def get_value(self): + def get_value(self) -> Expression: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -422,7 +472,7 @@ class Widget: return result return self.obj - def get_values(self): + def get_values(self) -> list[Expression]: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -430,13 +480,13 @@ class Widget: return [result] return [self.obj] - def get_number_value(self): + def get_number_value(self) -> Expression: value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) - def is_selected(self): + def is_selected(self) -> MockObj | None: """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons @@ -444,7 +494,7 @@ class Widget: """ return - def get_scale(self): + def get_scale(self) -> float: return self.type.get_scale(self.config) @@ -524,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 @@ -595,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""" @@ -613,46 +702,54 @@ 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) props = parts[CONF_MAIN][CONF_DEFAULT] - lambs = {} - flag_set = set() - flag_clr = set() - for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + for flag, value in props.items(): + if flag not in OBJ_FLAGS: + continue if isinstance(value, cv.Lambda): - lambs[prop] = value - elif value: - flag_set.add(prop) - else: - flag_clr.add(prop) - if flag_set: - adds = join_enums(flag_set, "LV_OBJ_FLAG_") - w.add_flag(adds) - if flag_clr: - clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") - w.clear_flag(clrs) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - flag = f"LV_OBJ_FLAG_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_flag(flag) - cond.else_() - w.clear_flag(flag) + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + w.set_flag(flag, value) for key, value in config.get(CONF_STATE, {}).items(): if isinstance(value, cv.Lambda): @@ -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/arc.py b/esphome/components/lvgl/widgets/arc.py index ac993cc382..4739412e09 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -13,12 +13,12 @@ from ..defines import ( ARC_MODES, CONF_ADJUSTABLE, CONF_CHANGE_RATE, + CONF_CLICKABLE, CONF_END_ANGLE, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - LV_OBJ_FLAG, LV_PART, ) from ..lv_validation import ( @@ -85,7 +85,7 @@ class ArcType(NumberType): if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) - w.clear_flag(LV_OBJ_FLAG.CLICKABLE) + w.set_flag(CONF_CLICKABLE, False) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 2137acb3f6..9dd5e84419 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -99,7 +99,7 @@ class CanvasType(WidgetType): if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" else: - color_format = "LV_COLOR_FORMAT_NATIVE" + color_format = "LV_COLOR_FORMAT_RGB565" # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) # stride is 0 for default (width * bytes_per_pixel) 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/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index f41cb8e4e9..98e1838f0f 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from ..automation import action_to_code from ..defines import ( CONF_ALIGN_TO, + CONF_CLICKABLE, CONF_MAIN, CONF_PAD_ROW, CONF_SCROLLBAR, @@ -441,7 +442,7 @@ async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: for event, conf in { event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS }.items(): - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger( conf[0], event_target, event, attach_obj=w.obj, user_data=user_data ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index fedc79383d..a49d58dc4d 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -38,7 +38,6 @@ from ..defines import ( CONF_SRC, CONF_START_VALUE, CONF_TICKS, - LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, add_lv_use, @@ -457,7 +456,7 @@ class MeterType(WidgetType): LV_EVENT.DRAW_TASK_ADDED, nullptr, ) - lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + lv_obj.set_send_draw_task_events(scale_var, True) if t == CONF_LINE: # Needle represented by a line diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 29087009cb..44d16bf614 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,5 +1,5 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT +from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT from esphome.core import ID from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr @@ -14,7 +14,6 @@ from ..defines import ( CONF_MSGBOXES, CONF_SRC, CONF_TITLE, - LV_OBJ_FLAG, TYPE_FLEX, add_lv_use, add_warning, @@ -141,7 +140,7 @@ async def msgbox_to_code(top_layer, conf): percent100 = await pixels_or_percent.process(1.0) lv_obj.set_size(outer, percent100, percent100) outer_widget.add_style(await OUTER_STYLE.get_var()) - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) lv.msgbox_add_title(msgbox, title) lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) @@ -153,7 +152,7 @@ async def msgbox_to_code(top_layer, conf): await widget_to_code(button, header_button_spec, msgbox) async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) if close_button: with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index 8bb8918f96..eab9be0f70 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) if CONF_CLOCK in config: canclock = CAN_CLOCK[config[CONF_CLOCK]] cg.add(var.set_mcp_clock(canclock)) diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 8a1973d022..599a864e54 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -43,6 +43,48 @@ bool MCP2515::setup_internal() { return true; } +canbus::CanEventFlags MCP2515::get_events() { + uint8_t events = 0; + uint8_t error_flags = this->get_error_flags_(); + + // Error-passive is one state for RX and TX together, so only a change of the combined flag is an event + bool was_passive = this->last_error_flags_ & (EFLG_RXEP | EFLG_TXEP); + bool is_passive = error_flags & (EFLG_RXEP | EFLG_TXEP); + if (was_passive != is_passive) { + events |= is_passive ? canbus::CAN_EVENT_PASSIVE : canbus::CAN_EVENT_ACTIVE; + } + if ((this->last_error_flags_ ^ error_flags) & EFLG_TXBO) { + events |= canbus::CAN_EVENT_BUS_OFF; + } + + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (error_flags & (EFLG_RX0OVR | EFLG_RX1OVR)) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + this->clear_rx_n_ovr_flags_(); + } + + this->last_error_flags_ = error_flags; + return static_cast(events); +} + +canbus::CanStatus MCP2515::get_status() { + canbus::CanStatus status = {}; + + uint8_t error_flags = this->get_error_flags_(); + status.bus_off = error_flags & EFLG_TXBO; + + // Transmit Error Counter is followed by Receive Error Counter so we're reading both at once + uint8_t err_counters[2]; + this->read_registers_(MCP_TEC, err_counters, 2); + + status.rx_error_counter = err_counters[1]; + status.tx_error_counter = err_counters[0]; + + return status; +} + canbus::Error MCP2515::reset_() { this->enable(); this->transfer_byte(INSTRUCTION_RESET); @@ -331,17 +373,6 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } -#ifdef ESPHOME_LOG_HAS_DEBUG - uint8_t err = get_error_flags_(); - // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set - // once both buffers are full. However, the RX0OVR flag is actually set instead. - // We can just check for both though because it doesn't break anything. - if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { - ESP_LOGD(TAG, "receive buffer overrun"); - clear_rx_n_ovr_flags_(); - } -#endif - return rc; } diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index 960e150800..8d2fe4d51b 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -58,6 +58,7 @@ class MCP2515 final : public canbus::Canbus, MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; void set_mcp_mode(const CanctrlReqopMode mode) { this->mcp_mode_ = mode; } + canbus::CanStatus get_status() override; static const struct TxBnRegs { REGISTER CTRL; REGISTER SIDH; @@ -74,7 +75,11 @@ class MCP2515 final : public canbus::Canbus, protected: CanClock mcp_clock_{MCP_8MHZ}; CanctrlReqopMode mcp_mode_ = CANCTRL_REQOP_NORMAL; + + uint8_t last_error_flags_{0}; + bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error set_mode_(CanctrlReqopMode mode); uint8_t read_register_(REGISTER reg); 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/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 5be6da88ef..0f623bfd72 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -31,13 +31,7 @@ static const LogString *detection_range_to_log_string(MHZ19DetectionRange range) } #endif -uint8_t mhz19_checksum(const uint8_t *command) { - uint8_t sum = 0; - for (uint8_t i = 1; i < MHZ19_REQUEST_LENGTH; i++) { - sum += command[i]; - } - return 0xFF - sum + 0x01; -} +uint8_t mhz19_checksum(const uint8_t *command) { return -sum_bytes(command + 1, MHZ19_REQUEST_LENGTH - 1); } void MHZ19Component::setup() { if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index f04f784ebc..9477ebc850 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -44,6 +44,7 @@ DOMAIN = "micro_wake_word" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" CONF_SLIDING_WINDOW_AVERAGE_SIZE = "sliding_window_average_size" CONF_SLIDING_WINDOW_SIZE = "sliding_window_size" @@ -426,6 +427,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -519,6 +521,8 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + if config[CONF_PREFER_INTERNAL_RAM]: + cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() if config.get(CONF_TASK_STACK_IN_PSRAM): @@ -569,6 +573,7 @@ async def to_code(config): cg.add( var.add_vad_model( prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_MICRO][CONF_TENSOR_ARENA_SIZE], @@ -581,6 +586,7 @@ async def to_code(config): model_parameters[CONF_ID], str(model_parameters[CONF_ID]), prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_WAKE_WORD], diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6d76e39986..4ae66d69bf 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -386,9 +386,10 @@ WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { } #ifdef USE_MICRO_WAKE_WORD_VAD -void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { - this->vad_model_ = make_unique(model_start, probability_cutoff, sliding_window_size, tensor_arena_size); +void MicroWakeWord::add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { + this->vad_model_ = + make_unique(model_start, model_size, probability_cutoff, sliding_window_size, tensor_arena_size); } #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 03f4a86fd4..862d40733b 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -93,8 +93,8 @@ class MicroWakeWord final : public Component uint8_t get_features_step_size() const { return this->features_step_size_; } #ifdef USE_MICRO_WAKE_WORD_VAD - void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + void add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 7cbd11711a..14e6046ba0 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -4,6 +4,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM +#include +#endif ESPHOME_LOG_TAG(TAG, "micro_wake_word"); @@ -62,16 +66,43 @@ bool StreamingModel::load_model_() { } if (this->tensor_arena_ == nullptr) { +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + RAMAllocator preferred_allocator(RAMAllocator::PREFER_INTERNAL); + this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory", + this->tensor_arena_size_); + } +#else this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); +#endif if (this->tensor_arena_ == nullptr) { ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); return false; } } +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) { + RAMAllocator internal_allocator(RAMAllocator::ALLOC_INTERNAL); + this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_); + if (this->model_copy_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data", + this->model_size_); + } else { + std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_); + } + } +#endif + if (this->interpreter_ == nullptr) { + const uint8_t *model_data = this->model_start_; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (this->model_copy_ != nullptr) + model_data = this->model_copy_.get(); +#endif this->interpreter_ = - make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, + make_unique(tflite::GetModel(model_data), this->streaming_op_resolver_, this->tensor_arena_, this->tensor_arena_size_, this->mrv_); if (this->interpreter_->AllocateTensors() != kTfLiteOk) { ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model"); @@ -176,6 +207,9 @@ size_t StreamingModel::probe_arena_size_() { void StreamingModel::unload_model() { this->interpreter_.reset(); +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_copy_.reset(); +#endif RAMAllocator arena_allocator; @@ -255,11 +289,15 @@ void StreamingModel::reset_probabilities() { this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION; } -WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only) { +WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, + bool internal_only) { this->id_ = id; this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_average_size; @@ -291,6 +329,9 @@ WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr m // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is // never loaded. this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = this->model_data_ ? this->model_data_->size() : 0; +#endif if (this->model_start_ == nullptr) { ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); } @@ -354,9 +395,12 @@ DetectionEvent WakeWordModel::determine_detected() { return detection_event; } -VADModel::VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { +VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_size; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 1cb9d6eba5..730afe32bf 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -5,6 +5,7 @@ #include "preprocessor_settings.h" #include "model_data.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include @@ -96,6 +97,10 @@ class StreamingModel { std::vector recent_streaming_probabilities_; const uint8_t *model_start_{nullptr}; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + size_t model_size_{0}; + RAMUniquePtr model_copy_; +#endif uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -108,6 +113,7 @@ class WakeWordModel final : public StreamingModel { /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer + /// @param model_size (size_t) size of the compiled-in model data /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling /// probability @@ -115,9 +121,9 @@ class WakeWordModel final : public StreamingModel { /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena /// @param default_enabled (bool) If true, it will be enabled by default on first boot /// @param internal_only (bool) If true, the model will not be exposed to HomeAssistant as an available model - WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only); + WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, + size_t tensor_arena_size, bool default_enabled, bool internal_only); /// @brief Constructs a wake word model object with a runtime-downloaded model /// @param id (std::string) identifier for this model @@ -173,8 +179,8 @@ class WakeWordModel final : public StreamingModel { class VADModel final : public StreamingModel { public: - VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); void log_model_config() override; diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 914361a4ac..3ebaf53f5b 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,3 +1,5 @@ +from esphome.components.mipi import SLPOUT, delay + from . import DsiDriverChip # fmt: off @@ -34,6 +36,61 @@ DsiDriverChip( ], ) +# JC1060P470-V2: 2026 batch of the JC1060P470 with a different 7" panel behind the same JD9165 driver. +# The rear label shows "V2" after the material number. Timings and init sequence taken from Guition's +# reference driver JC1060P470C_I_W_Y.zip (2026-07-13), +# 1-Demo/.../JC1060P470C_I_W_Y_New_Panel/lvgl_demo_v8/src/lcd/esp_lcd_jd9165.{c,h} +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 1024x600 +# * PCLK Frequency: 52 MHz +# * DSI Lane Bit Rate: 750 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=24, hsync_back_porch=136, hsync_front_porch=160) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=21, vsync_front_porch=12) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC1060P470-V2", + width=1024, + height=600, + hsync_back_porch=136, + hsync_pulse_width=24, + hsync_front_porch=160, + vsync_back_porch=21, + vsync_pulse_width=2, + vsync_front_porch=12, + pclk_frequency="52MHz", + lane_bit_rate="750Mbps", + color_order="RGB", + # LCD_RST is GPIO0 on the JC1060P470C_I_W_Y schematic (FPC1 pin 4); the JD9165 needs 120 ms after reset + # before it accepts the vendor table and 120 ms after SLPOUT before DISPON (Guition esp_lcd_jd9165.c); + # the framework adds another 10 ms after SLPOUT. + # SLPOUT is in the init sequence with its own delay, so the framework must not append a second one. + reset_pin=0, + no_slpout=True, + initsequence=[ + delay(120), + (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x08), (0x0B, 0x11), + (0x20, 0x04), (0x1F, 0x05), (0x23, 0x38), (0x28, 0x18), (0x29, 0x29), (0x2A, 0x01), (0x2B, 0x29), + (0x2C, 0x01), (0x30, 0x02), (0x00, 0x05), (0x01, 0x22), (0x02, 0x08), (0x03, 0x12), (0x04, 0x16), + (0x05, 0x64), (0x06, 0x00), (0x07, 0x00), (0x08, 0x78), (0x09, 0x00), (0x0A, 0x04), + (0x0B, 0x16, 0x17, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x07, 0x07, 0x09), + (0x0C, 0x09, 0x1E, 0x1E, 0x1C, 0x1C, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0D, 0x0A, 0x05, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x06, 0x06, 0x08), + (0x0E, 0x08, 0x1F, 0x1F, 0x1D, 0x1D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0F, 0x0A, 0x05, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1D, 0x1D, 0x1F), + (0x10, 0x1F, 0x08, 0x08, 0x06, 0x06, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x11, 0x16, 0x17, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1C, 0x1C, 0x1E), + (0x12, 0x1E, 0x09, 0x09, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x13, 0x00, 0x00, 0x00, 0x00), (0x14, 0x00, 0x00, 0x41, 0x41), (0x15, 0x00, 0x00, 0x00, 0x00), + (0x17, 0x00), (0x18, 0x85), (0x19, 0x06, 0x09), (0x1A, 0x05, 0x08), (0x1B, 0x0A, 0x04), (0x26, 0x00), + (0x27, 0x00), (0x30, 0x06), + (0x12, 0x3F, 0x25, 0x27, 0x35, 0x1D, 0x1B, 0x1B, 0x1A, 0x18, 0x0A, 0x2A, 0x21, 0x19, 0x30), + (0x13, 0x3F, 0x26, 0x27, 0x35, 0x1E, 0x1C, 0x1C, 0x1A, 0x18, 0x0B, 0x2A, 0x21, 0x19, 0x30), + (0x30, 0x0A), (0x02, 0x4F), (0x0B, 0x40), (0x30, 0x0D), (0x0D, 0x04), (0x10, 0x05), (0x11, 0x0C), + (0x12, 0x05), (0x13, 0x0C), (0x30, 0x00), + (SLPOUT,), delay(120), + ], +) + # JC4880P443 Driver Configuration (ST7701) # Using parameters from esp_lcd_st7701.h and the working full init sequence 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/lcdwiki.py b/esphome/components/mipi_spi/models/lcdwiki.py new file mode 100644 index 0000000000..6ef8b1fad7 --- /dev/null +++ b/esphome/components/mipi_spi/models/lcdwiki.py @@ -0,0 +1,86 @@ +# Init sequence sourced from LCDwiki's own published Arduino example for this board: +# https://github.com/ydedox/st77922 (Example_01_Simple_test/Simple_test/Simple_test.ino) +# It is the commented-out panel table in that file, with the 0x71 and 0xBE values from the +# active one, which is what the shipped firmware writes. + +from esphome.components.mipi import MODE_RGB, SLPOUT, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +ES3C35P = DriverChip( + "ES3C35P", + width=320, + height=480, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + color_order=MODE_RGB, + invert_colors=True, + bus_mode=TYPE_QUAD, + data_rate="40MHz", # datasheet caps the write clock at 62.5MHz + cs_pin=10, + reset_delay=120, + no_slpout=True, # SLPOUT is in the init sequence, the panel needs 120ms after it + requires={"psram"}, + initsequence=( + (0xF1, 0x00), + (0x60, 0x00, 0x00, 0x00), + (0x65, 0x80), + (0x79, 0x06), + (0x7B, 0x00, 0x08, 0x08), + (0x80, 0x55, 0x62, 0x2F, 0x17, 0xF0, 0x52, 0x70, 0xD2, 0x52, 0x62, 0xEA), + (0x81, 0x26, 0x52, 0x72, 0x27), + (0x84, 0x92, 0x25), + (0x87, 0x10, 0x10, 0x58, 0x00, 0x02, 0x3A), + (0x88, 0x00, 0x00, 0x2C, 0x10, 0x04, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x06), + (0x89, 0x00, 0x00, 0x00), + (0x8A, 0x13, 0x00, 0x2C, 0x00, 0x00, 0x2C, 0x10, 0x10, 0x00, 0x3E, 0x19), + (0x8B, 0x15, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x97, 0x8E), + (0x8C, 0x1D, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x50, 0x0F, 0x01, 0xC5, 0x12, 0x09), + (0x8D, 0x0C), + (0x8E, 0x33, 0x01, 0x0C, 0x13, 0x01, 0x01), + (0xB3, 0x00, 0x30), + (0x71, 0xD0), + (0x66, 0x02, 0x3F), + (0xBE, 0x26, 0x00, 0x9D), + (0x70, 0x01, 0xA6, 0x11, 0x40, 0xE0, 0x00, 0x11, 0x60, 0x11, 0x00, 0x00, 0x1A), + (0x90, 0x04, 0x04, 0x55, 0x74, 0x00, 0x40, 0x43, 0x2D, 0x2D), + (0x91, 0x04, 0x04, 0x55, 0x75, 0x00, 0x40, 0x42, 0x2D, 0x2D), + (0x92, 0x04, 0x44, 0x55, 0xC0, 0x06, 0x00, 0x07, 0x05, 0x90, 0x2D), + (0x93, 0x04, 0x43, 0x11, 0x00, 0x00, 0x00, 0x00, 0x05, 0x90, 0x2D), + (0x94, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0x95, 0x96, 0x16, 0x00, 0x00, 0xFF), + (0x96, 0x44, 0x53, 0x03, 0x12, 0x23, 0x24, 0x06, 0x05, 0x9A, 0x2D, 0x00, 0x44), + (0x97, 0x44, 0x53, 0x47, 0x56, 0x20, 0x20, 0x02, 0x01, 0x9A, 0x2D, 0x00, 0x44), + (0xBA, 0x55, 0x9A, 0x2D, 0x9A, 0x2D), + (0x9A, 0x40, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9B, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9C, 0x5C, 0x12, 0x00, 0x00, 0x10, 0x12, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x00), + (0x9D, 0x8A, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0x9E, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0xB4, 0x1D, 0x1C, 0x1E, 0x0B, 0x14, 0x02, 0x13, 0x09, 0x1E, 0x00, 0x1E, 0x10), + (0xB5, 0x1D, 0x1C, 0x1E, 0x0A, 0x15, 0x03, 0x11, 0x08, 0x1E, 0x01, 0x1E, 0x12), + (0xB6, 0x77, 0x77, 0x00, 0x0A, 0xFF, 0x0A, 0xFF), + (0x86, 0xC6, 0x04, 0xB1, 0x02, 0x58, 0x12, 0x58, 0x0C, 0x13, 0x01, 0xA5, 0x00, 0xA5, 0xA5), + (0xB7, 0x07, 0x0A, 0x0E, 0x06, 0x05, 0x03, 0x2B, 0x03, 0x03, 0x42, 0x07, 0x10, 0x10, 0x2E, 0x3F, 0x0D), + (0xB8, 0x07, 0x0A, 0x0D, 0x05, 0x05, 0x02, 0x2B, 0x02, 0x03, 0x42, 0x06, 0x10, 0x0F, 0x2E, 0x3F, 0x0D), + (0xB9, 0x23, 0x23), + (0xBF, 0x10, 0x14, 0x14, 0x0B, 0x0B, 0x0B), + (0xF2, 0x00), + (0x73, 0x04, 0xDA, 0x12, 0x54, 0x47), + (0x77, 0x6B, 0x5B, 0xFD, 0xC3, 0xC5), + (0x7A, 0x15, 0x27), + (0x7B, 0x04, 0x57), + (0x7E, 0x01, 0x0E), + (0xBF, 0x36), + (0xE3, 0x40, 0x40), + (0xF0, 0x00), + (0xD0, 0x00), + (SLPOUT,), + delay(120), + ), +) +# fmt: on + +# The speakerless variant uses the same display +ES3C35P.extend("ES3C35P-NS") 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/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 515cd81fdf..9fba222d63 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -242,9 +242,7 @@ void MitsubishiClimate::transmit_state() { } // Checksum - for (int i = 0; i < 17; i++) { - remote_state[17] += remote_state[i]; - } + remote_state[17] = sum_bytes(remote_state, 17); ESP_LOGD(TAG, "sending: %02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X", remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index c6c7b57c5f..3261d9d569 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -31,7 +30,7 @@ static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { - return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); + return static_cast(0xFC - sum_bytes(bytes, length)); } template diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index eecaa1a1d4..98dd0b74ec 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -19,8 +19,8 @@ constexpr uint8_t CRC_OFFSET = 0x20; // Extracts a TAB-delimited field from [buf_start, buf_end) into dest. // Returns the field length, or 0 if no TAB was found, or the (uncopied) field // length if it's >= max_len. -static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { - const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); +static size_t get_field(char *dest, const uint8_t *buf_start, const uint8_t *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); if (!field_end) return 0; const size_t len = field_end - buf_start; @@ -34,20 +34,13 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, return len; } -// Calculates the CRC (checksum) for a given group of characters. -uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - uint8_t crc_tmp{0}; - const auto effective_len = grp_len - CRC_SUFFIX_LEN; - for (size_t i = 0; i < effective_len; i++) { - crc_tmp += grp[i]; - } - crc_tmp &= CRC_MASK; - crc_tmp += CRC_OFFSET; - return crc_tmp; +// Calculates the CRC (checksum) for a given group of bytes. +uint8_t Mk2PVRouter::calculate_crc_(const uint8_t *grp, size_t grp_len) { + return (sum_bytes(grp, grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. -bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { +bool Mk2PVRouter::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { const auto grp_len = grp_end - grp; if (grp_len < static_cast(CRC_SUFFIX_LEN)) { ESP_LOGE(TAG, "Empty or too short group"); @@ -65,7 +58,7 @@ bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { } // Validates, parses, and publishes a single tag/value group. -void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { +void Mk2PVRouter::process_group_(const uint8_t *grp, const uint8_t *grp_end) { if (!this->check_crc_(grp, grp_end)) return; @@ -90,9 +83,9 @@ bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { size_t j{0}; while (this->available() > 0 && j++ < MAX_ITERATIONS) { - const auto received = this->read(); - if (received < 0) - continue; + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -134,10 +127,10 @@ void Mk2PVRouter::loop() { // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) // CRC is computed over "Tag | TAB | Data | TAB". - while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { ++buf_finger; // Skip LF to the start of the group. - auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index 1a6af4b1b3..061a9a22e9 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -57,12 +57,12 @@ class Mk2PVRouter : public Component, public uart::UARTDevice { State state_{State::WAITING_FOR_START}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; - char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + uint8_t buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding bool read_chars_until_(bool drop, uint8_t c); - uint8_t calculate_crc_(const char *grp, size_t grp_len); - bool check_crc_(const char *grp, const char *grp_end); - void process_group_(const char *grp, const char *grp_end); + uint8_t calculate_crc_(const uint8_t *grp, size_t grp_len); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); + void process_group_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const char *tag, const char *val); }; } // namespace esphome::mk2pvrouter diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 5294f20f2d..67c3cd75bd 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -113,20 +113,18 @@ def _packed_bit_bytes(bits: int) -> int: return (bits + 7) // 8 -def _synchronous_handler(value: ConfigType) -> ConfigType: - """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused - once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " - "handlers: the request/response data is only valid while the handler runs. Copy what you " - "need into globals first, then defer in a separate script or automation." - ) - return value +# Handler PDU spans point into hub buffers that are reused once the handler returns. +_HANDLER_CONTEXT = "modbus_client handlers" +_HANDLER_DATA = "the request/response data" +_synchronous_handler = automation.validate_no_deferring_actions( + _HANDLER_CONTEXT, _HANDLER_DATA +) def _handler_schema() -> cv.All: - return cv.All(automation.validate_automation(single=True), _synchronous_handler) + return automation.validate_synchronous_automation( + _HANDLER_CONTEXT, _HANDLER_DATA, single=True + ) # Each action is its own hub device: the modbus hub routes the reply straight back to the action that diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index b21dc2e2f0..826d4e32ef 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -224,6 +224,17 @@ def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: return config +# The parsed JSON document is freed once the automation returns +JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( + "on_json_message", + "the parsed JSON document", + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), + cv.Required(CONF_TOPIC): cv.subscribe_topic, + cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, + }, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -304,15 +315,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PAYLOAD): cv.string_strict, } ), - cv.Optional(CONF_ON_JSON_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTJsonMessageTrigger - ), - cv.Required(CONF_TOPIC): cv.subscribe_topic, - cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, - } - ), + cv.Optional(CONF_ON_JSON_MESSAGE): JSON_MESSAGE_AUTOMATION_SCHEMA, cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } 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/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/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/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 44eb546850..68ee263e7c 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -16,6 +16,7 @@ void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Eui64", this); } void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Name", this); } void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key", this); } void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } +void MeshLocalEidOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Mesh Local EID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } } // namespace esphome::openthread_info diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index ef7c5cc8e9..1f97c57780 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -134,6 +134,24 @@ class ChannelOpenThreadInfo final : public OpenThreadInstancePollingComponent, p uint8_t last_channel_; }; +class MeshLocalEidOpenThreadInfo final : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance(otInstance *instance) override { + const otIp6Address *addr = otThreadGetMeshLocalEid(instance); + if (otIp6IsAddressEqual(addr, &this->last_addr_)) { + return; + } + this->last_addr_ = *addr; + char buf[OT_IP6_ADDRESS_STRING_SIZE]; + otIp6AddressToString(addr, buf, sizeof(buf)); + this->publish_state(buf); + } + void dump_config() override; + + protected: + otIp6Address last_addr_{}; +}; + class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { public: void update_instance(otInstance *instance) override { diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index 4d3e8cd6c3..c7e8a60298 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" +CONF_MESH_LOCAL_EID = "mesh_local_eid" DEPENDENCIES = ["openthread"] @@ -46,6 +47,9 @@ NetworkKeyOpenThreadInfo = openthread_info_ns.class_( PanIdOpenThreadInfo = openthread_info_ns.class_( "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) +MeshLocalEidOpenThreadInfo = openthread_info_ns.class_( + "MeshLocalEidOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) ExtPanIdOpenThreadInfo = openthread_info_ns.class_( "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) @@ -80,6 +84,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_MESH_LOCAL_EID): text_sensor.text_sensor_schema( + MeshLocalEidOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), @@ -103,4 +110,5 @@ async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_NETWORK_NAME) await setup_conf(config, CONF_NETWORK_KEY) await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_MESH_LOCAL_EID) await setup_conf(config, CONF_EXT_PAN_ID) 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/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ade93b8735..ea7ebaed0a 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -286,6 +286,12 @@ void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const c case POLLING_QMN: handle_qmn_(message); break; + case POLLING_QET: + handle_single_int_(message, this->last_qet_, this->total_pv_generated_energy_); + break; + case POLLING_QLT: + handle_single_int_(message, this->last_qlt_, this->total_output_load_energy_); + break; default: break; } @@ -688,6 +694,17 @@ void Pipsolar::handle_qmn_(const char *message) { } } +// QET and QLT each answer with one integer +void Pipsolar::handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value) { + if (raw) { + raw->publish_state(message); + } + + size_t pos = 0; + this->skip_start_(message, &pos); + this->read_int_sensor_(message, &pos, value); +} + void Pipsolar::skip_start_(const char *message, size_t *pos) { if (message[*pos] == '(') { (*pos)++; diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 06c920a6e4..4695e39829 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -19,6 +19,8 @@ enum ENUMPollingCommand { POLLING_QPIWS = 4, POLLING_QT = 5, POLLING_QMN = 6, + POLLING_QET = 7, + POLLING_QLT = 8, }; struct PollingCommand { uint8_t *command; @@ -116,6 +118,12 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI) PIPSOLAR_SENSOR(pv_power_balance, QPIRI) + // QET value + PIPSOLAR_SENSOR(total_pv_generated_energy, QET) + + // QLT value + PIPSOLAR_SENSOR(total_output_load_energy, QLT) + // QMOD values PIPSOLAR_TEXT_SENSOR(device_mode, QMOD) @@ -175,6 +183,8 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_TEXT_SENSOR(last_qpiws, QPIWS) PIPSOLAR_TEXT_SENSOR(last_qt, QT) PIPSOLAR_TEXT_SENSOR(last_qmn, QMN) + PIPSOLAR_TEXT_SENSOR(last_qet, QET) + PIPSOLAR_TEXT_SENSOR(last_qlt, QLT) PIPSOLAR_SWITCH(output_source_priority_utility_switch, QPIRI) PIPSOLAR_SWITCH(output_source_priority_solar_switch, QPIRI) @@ -213,6 +223,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { void handle_qpiws_(const char *message); void handle_qt_(const char *message); void handle_qmn_(const char *message); + void handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value); void skip_start_(const char *message, size_t *pos); void skip_field_(const char *message, size_t *pos); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 5a697157b7..02f9e239c7 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -17,6 +18,7 @@ from esphome.const import ( ICON_FLASH, ICON_GAUGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -80,6 +83,10 @@ CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON = "battery_voltage_offset_for_fans_on" CONF_EEPROM_VERSION = "eeprom_version" CONF_PV_CHARGING_POWER = "pv_charging_power" +# QET / QLT sensors +CONF_TOTAL_PV_GENERATED_ENERGY = "total_pv_generated_energy" +CONF_TOTAL_OUTPUT_LOAD_ENERGY = "total_output_load_energy" + TYPES = { CONF_GRID_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -319,6 +326,18 @@ TYPES = { device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + CONF_TOTAL_PV_GENERATED_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_TOTAL_OUTPUT_LOAD_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index cc7477395b..0986120dcf 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -15,6 +15,8 @@ CONF_LAST_QFLAG = "last_qflag" CONF_LAST_QPIWS = "last_qpiws" CONF_LAST_QT = "last_qt" CONF_LAST_QMN = "last_qmn" +CONF_LAST_QET = "last_qet" +CONF_LAST_QLT = "last_qlt" TYPES = [ CONF_DEVICE_MODE, @@ -25,6 +27,8 @@ TYPES = [ CONF_LAST_QPIWS, CONF_LAST_QT, CONF_LAST_QMN, + CONF_LAST_QET, + CONF_LAST_QLT, ] CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index ebd508b535..2f57c02843 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -43,11 +43,7 @@ void PM1006Component::loop() { } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional PM1006Component::check_byte_() const { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index 14b1f60df5..f99e5adaa2 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -189,6 +189,7 @@ bool PMSX003Component::check_payload_length_(uint16_t payload_length) { case Type::PMS5003ST: // Data 16 not set/reserved return payload_length == 36; // 2*17+2 case Type::PMS9003M: + case Type::LUFTMY: return payload_length == 28; // 2*13+2 } return false; @@ -219,40 +220,48 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { } void PMSX003Component::parse_data_() { - // Particle Matter + // PM mass concentrations const uint16_t pm_1_0_std_concentration = this->get_16_bit_uint_(4); const uint16_t pm_2_5_std_concentration = this->get_16_bit_uint_(6); const uint16_t pm_10_0_std_concentration = this->get_16_bit_uint_(8); - const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); - const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); - const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); - - const uint16_t pm_particles_03um = this->get_16_bit_uint_(16); - const uint16_t pm_particles_05um = this->get_16_bit_uint_(18); - const uint16_t pm_particles_10um = this->get_16_bit_uint_(20); - const uint16_t pm_particles_25um = this->get_16_bit_uint_(22); - - ESP_LOGD(TAG, - "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " - "Concentration: %u µg/m³, PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 " - "Concentration: %u µg/m³", - pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration, pm_1_0_concentration, - pm_2_5_concentration, pm_10_0_concentration); - if (this->pm_1_0_std_sensor_ != nullptr) this->pm_1_0_std_sensor_->publish_state(pm_1_0_std_concentration); if (this->pm_2_5_std_sensor_ != nullptr) this->pm_2_5_std_sensor_->publish_state(pm_2_5_std_concentration); if (this->pm_10_0_std_sensor_ != nullptr) this->pm_10_0_std_sensor_->publish_state(pm_10_0_std_concentration); + ESP_LOGD(TAG, + "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " + "Concentration: %u µg/m³", + pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration); - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + // The Luftmy LD10, LD11, LD13, LD15 and LD16 report no atmospheric PM, so their particle counts start at byte 10. + // All PMS types have atmospheric PM at bytes 10-15, so their particle counts start at byte 16. + const bool is_luftmy = this->type_ == Type::LUFTMY; + const uint8_t pc_offset = is_luftmy ? 10 : 16; + + if (!is_luftmy) { + const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); + const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); + const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); + + ESP_LOGD(TAG, "Got PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 Concentration: %u µg/m³", + pm_1_0_concentration, pm_2_5_concentration, pm_10_0_concentration); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + } + + // Particle counts at pc_offset: >0.3, >0.5, >1.0, >2.5µm + const uint16_t pm_particles_03um = this->get_16_bit_uint_(pc_offset); + const uint16_t pm_particles_05um = this->get_16_bit_uint_(pc_offset + 2); + const uint16_t pm_particles_10um = this->get_16_bit_uint_(pc_offset + 4); + const uint16_t pm_particles_25um = this->get_16_bit_uint_(pc_offset + 6); if (this->pm_particles_03um_sensor_ != nullptr) this->pm_particles_03um_sensor_->publish_state(pm_particles_03um); @@ -262,22 +271,17 @@ void PMSX003Component::parse_data_() { this->pm_particles_10um_sensor_->publish_state(pm_particles_10um); if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + ESP_LOGD(TAG, + "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " + "PM2.5 Particles: %u Count/0.1L", + pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - if (this->type_ == Type::PMS5003T) { - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - } else { - // Note the pm particles 50um & 100um are not returned, - // as PMS5003T uses those data values for temperature and humidity. - const uint16_t pm_particles_50um = this->get_16_bit_uint_(24); - const uint16_t pm_particles_100um = this->get_16_bit_uint_(26); + // PMS5003T uses pc_offset+8 and pc_offset+10 for temperature/humidity, not particle counts + if (this->type_ != Type::PMS5003T) { + const uint16_t pm_particles_50um = this->get_16_bit_uint_(pc_offset + 8); + const uint16_t pm_particles_100um = this->get_16_bit_uint_(pc_offset + 10); - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L, PM5.0 Particles: %u Count/0.1L, PM10.0 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um, pm_particles_50um, + ESP_LOGD(TAG, "Got PM5.0 Particles: %u Count/0.1L, PM10.0 Particles: %u Count/0.1L", pm_particles_50um, pm_particles_100um); if (this->pm_particles_50um_sensor_ != nullptr) diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index c62960e7c3..c123a42b9e 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -15,6 +15,7 @@ enum class Type : uint8_t { PMS5003T, PMS5003ST, PMS9003M, + LUFTMY, // Luftmy LD10, LD11, LD13, LD15, LD16: standard PM + particle counts, no atmospheric PM values }; enum class Command : uint8_t { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 5eac5415ed..723b99a504 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -51,6 +51,7 @@ TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" TYPE_PMS9003M = "PMS9003M" +TYPE_LUFTMY = "LUFTMY" Type = pmsx003_ns.enum("Type", is_class=True) @@ -62,6 +63,7 @@ PMSX003_TYPES = { TYPE_PMS5003T: Type.PMS5003T, TYPE_PMS5003ST: Type.PMS5003ST, TYPE_PMS9003M: Type.PMS9003M, + TYPE_LUFTMY: Type.LUFTMY, } SENSORS_TO_TYPE = { @@ -73,6 +75,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5_STD: [ TYPE_PMS1003, @@ -82,6 +85,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0_STD: [ TYPE_PMS1003, @@ -91,6 +95,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0: [ TYPE_PMS1003, @@ -126,6 +131,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_0_5UM: [ TYPE_PMS1003, @@ -134,6 +140,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0UM: [ TYPE_PMS1003, @@ -142,6 +149,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5UM: [ TYPE_PMS1003, @@ -150,6 +158,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_5_0UM: [ TYPE_PMS1003, @@ -157,6 +166,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0UM: [ TYPE_PMS1003, @@ -164,6 +174,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 1687edd511..5326aeb145 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -71,11 +71,7 @@ bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble constexpr size_t tfi_offset = 6; - uint8_t checksum = 0; - for (size_t i = 0; i < len + 1U; i++) { - checksum += data[tfi_offset + i]; - } - checksum = ~checksum + 1; + const uint8_t checksum = -sum_bytes(data.data() + tfi_offset, len + 1U); if (data[tfi_offset + len + 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index e86153be86..34c6a3e44f 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -127,12 +127,8 @@ bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Response data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - uint8_t checksum = header[5] + header[6]; // TFI + Command response code - for (int i = 0; i < len - 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - checksum = ~checksum + 1; + // TFI + Command response code + data + const uint8_t checksum = -sum_bytes(data.data(), len - 1, header[5] + header[6]); if (data[len - 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len - 1], checksum); 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/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 7809e803c9..606a03e0c2 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -34,9 +34,7 @@ void PZEM004T::loop() { // 1-5: data // 6: checksum (sum of other bytes) // see https://github.com/olehs/PZEM004T - uint8_t sum = 0; - for (int i = 0; i < 6; i++) - sum += resp[i]; + const uint8_t sum = sum_bytes(resp.data(), 6); if (sum != resp[6]) { ESP_LOGV(TAG, "PZEM004T invalid checksum! 0x%02X != 0x%02X", sum, resp[6]); diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a880169fb2..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, @@ -47,16 +54,23 @@ from esphome.const import ( 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") @@ -255,10 +269,23 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) -def register_action(name, type_, schema): - validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_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 +): + action, _, validator = _transmit_action(name, schema, validate) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", + action, type_, validator, synchronous=True, @@ -283,6 +310,75 @@ def register_action(name, type_, schema): 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) @@ -366,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, @@ -402,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 @@ -417,7 +511,7 @@ async def beo4_action(var, config, args): BrennenstuhlData, BrennenstuhlBinarySensor, BrennenstuhlTrigger, - BrennenstuhlAction, + _, BrennenstuhlDumper, ) = declare_protocol("Brennenstuhl") @@ -450,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 @@ -461,7 +557,7 @@ async def brennenstuhl_action(var, config, args): ByronSXData, ByronSXBinarySensor, ByronSXTrigger, - ByronSXAction, + _, ByronSXDumper, ) = declare_protocol("ByronSX") BYRONSX_SCHEMA = cv.Schema( @@ -497,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 @@ -510,7 +607,7 @@ async def byronsx_action(var, config, args): CanalSatData, CanalSatBinarySensor, CanalSatTrigger, - CanalSatAction, + _, CanalSatDumper, ) = declare_protocol("CanalSat") CANALSAT_SCHEMA = cv.Schema( @@ -546,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( @@ -596,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 @@ -611,7 +708,7 @@ async def canalsatld_action(var, config, args): CoolixData, CoolixBinarySensor, CoolixTrigger, - CoolixAction, + _, CoolixDumper, ) = declare_protocol("Coolix") @@ -641,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) @@ -660,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), @@ -694,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), @@ -741,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, @@ -788,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}) @@ -823,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, @@ -862,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 @@ -875,7 +971,7 @@ async def lg_action(var, config, args): MagiQuestData, MagiQuestBinarySensor, MagiQuestTrigger, - MagiQuestAction, + _, MagiQuestDumper, ) = declare_protocol("MagiQuest") @@ -886,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])) ) ) @@ -910,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 @@ -923,7 +1026,7 @@ async def magiquest_action(var, config, args): KeeloqData, KeeloqBinarySensor, KeeloqTrigger, - KeeloqAction, + _, KeeloqDumper, ) = declare_protocol("Keeloq") KEELOQ_SCHEMA = cv.Schema( @@ -939,6 +1042,26 @@ 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.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] + ) + return config + + +KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( + { + # 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), + } +) + + @register_binary_sensor("keeloq", KeeloqBinarySensor, KEELOQ_SCHEMA) def Keeloq_binary_sensor(var, config): cg.add( @@ -962,25 +1085,28 @@ def keeloq_dumper(var, config): pass -@register_action("keeloq", KeeloqAction, KEELOQ_SCHEMA) -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_)) +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, +) # 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), } ) @@ -1009,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)}) @@ -1048,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 @@ -1061,7 +1187,7 @@ async def onkyori_action( PioneerData, PioneerBinarySensor, PioneerTrigger, - PioneerAction, + _, PioneerDumper, ) = declare_protocol("Pioneer") PIONEER_SCHEMA = cv.Schema( @@ -1095,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 @@ -1108,7 +1253,7 @@ async def pioneer_action(var, config, args): ProntoData, ProntoBinarySensor, ProntoTrigger, - ProntoAction, + _, ProntoDumper, ) = declare_protocol("Pronto") PRONTO_SCHEMA = cv.Schema( @@ -1142,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 @@ -1153,7 +1298,7 @@ async def pronto_action(var, config, args): GoboxData, GoboxBinarySensor, GoboxTrigger, - GoboxAction, + _, GoboxDumper, ) = declare_protocol("Gobox") GOBOX_SCHEMA = cv.Schema( @@ -1185,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 @@ -1196,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}) @@ -1224,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, @@ -1265,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( @@ -1309,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 @@ -1395,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) ) ) ) @@ -1431,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)), @@ -1474,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, @@ -1517,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 @@ -1838,7 +2047,7 @@ def rc_switch_dumper(var, config): SamsungData, SamsungBinarySensor, SamsungTrigger, - SamsungAction, + _, SamsungDumper, ) = declare_protocol("Samsung") SAMSUNG_SCHEMA = cv.Schema( @@ -1872,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 @@ -1885,7 +2095,7 @@ async def samsung_action(var, config, args): Samsung36Data, Samsung36BinarySensor, Samsung36Trigger, - Samsung36Action, + _, Samsung36Dumper, ) = declare_protocol("Samsung36") SAMSUNG36_SCHEMA = cv.Schema( @@ -1919,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 @@ -1932,7 +2143,7 @@ async def samsung36_action(var, config, args): ToshibaAcData, ToshibaAcBinarySensor, ToshibaAcTrigger, - ToshibaAcAction, + _, ToshibaAcDumper, ) = declare_protocol("ToshibaAc") TOSHIBAAC_SCHEMA = cv.Schema( @@ -1966,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 @@ -1979,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, @@ -2062,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)), @@ -2098,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, @@ -2149,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( @@ -2159,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 @@ -2175,7 +2560,7 @@ async def aeha_action(var, config, args): Hob2HoodData, Hob2HoodBinarySensor, Hob2HoodTrigger, - Hob2HoodAction, + _, Hob2HoodDumper, ) = declare_protocol("Hob2Hood") @@ -2217,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( @@ -2259,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 @@ -2271,7 +2655,7 @@ async def haier_action(var, config, args): ABBWelcomeData, ABBWelcomeBinarySensor, ABBWelcomeTrigger, - ABBWelcomeAction, + _, ABBWelcomeDumper, ) = declare_protocol("ABBWelcome") @@ -2323,52 +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) - ) - ) - if CONF_DATA in config: - data_ = config[CONF_DATA] - if cg.is_template(data_): - template_ = await cg.templatable( - data_, args, cg.std_vector.template(cg.uint8) - ) - cg.add(var.set_data_template(template_)) - else: - arr_id = ID(f"{var.base}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data_)) - cg.add(var.set_data_static(arr, len(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 @@ -2376,7 +2736,7 @@ async def abbwelcome_action(var, config, args): MirageData, MirageBinarySensor, MirageTrigger, - MirageAction, + _, MirageDumper, ) = declare_protocol("Mirage") @@ -2402,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 @@ -2414,7 +2776,7 @@ async def mirage_action(var, config, args): TotoData, TotoBinarySensor, TotoTrigger, - TotoAction, + _, TotoDumper, ) = declare_protocol("Toto") @@ -2451,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 a309c124ee..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,14 +112,21 @@ 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()]; } - void set_data(std::vector data) { - uint8_t size = std::min(MAX_DATA_LENGTH, static_cast(data.size())); + /// 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.begin(), 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 { std::vector data(this->data_.begin() + 5 + 2 * this->get_address_length(), this->data_.begin() + 5 + 2 * this->get_address_length() + this->get_data_size()); @@ -225,50 +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) - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } - 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...); - std::vector data_vec; - if (this->len_ > 0) { - // Static mode: copy from flash to vector - data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else if (this->len_ < 0) { - // Template mode: call function - data_vec = this->data_.func(x...); - } - data.set_data(data_vec); - data.finalize(); - ABBWelcomeProtocol().encode(dst, data); - } - - protected: - ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index eb05f7d21e..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 @@ -14,20 +15,21 @@ static constexpr uint16_t BIT_ONE_LOW_US = BITWISE * 3; static constexpr uint16_t BIT_ZERO_LOW_US = BITWISE; static constexpr uint16_t TRAILER = BITWISE; -void AEHAProtocol::encode(RemoteTransmitData *dst, const AEHAData &data) { - dst->reserve(2 + 32 + (data.data.size() * 2) + 1); +void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len) { + dst->reserve(2 + 32 + (len * 2) + 1); dst->item(HEADER_HIGH_US, HEADER_LOW_US); for (uint16_t mask = 1 << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { + if (address & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); } else { dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); } } - for (uint8_t bit : data.data) { + for (size_t i = 0; i < len; 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 57706a7e4f..ab762612e1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,7 +15,14 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data); + 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); @@ -25,19 +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 { - AEHAData data{}; - data.address = this->address_.value(x...); - data.data = this->data_.value(x...); - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - AEHAProtocol().encode(dst, data); - } -}; - } // 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 3c9ba7b61f..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" @@ -29,16 +30,17 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { } } -void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { +void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 16)); + dst->reserve(5 + ((len + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); dst->space(HEADER_HIGH_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; 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 90af88d2c7..0594049471 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,7 +13,12 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data); + 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); @@ -23,15 +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 { - HaierData data{}; - data.data = this->code_.value(x...); - HaierProtocol().encode(dst, data); - } -}; - } // 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.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index caa950218e..fff8eb897f 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -19,24 +19,29 @@ static constexpr uint8_t NBITS_SYNC_CNT = 16; static constexpr uint8_t NBITS_FIXED_DATA = NBITS_REPEAT + NBITS_VLOW + NBITS_BUTTONS + NBITS_SERIAL; static constexpr uint8_t NBITS_ENCRYPTED_DATA = NBITS_BUTTONS + NBITS_DISC + NBITS_SYNC_CNT; static constexpr uint8_t NBITS_DATA = NBITS_FIXED_DATA + NBITS_ENCRYPTED_DATA; +static constexpr uint8_t NBITS_SUFFIX_MAX = 16; +// A space this long is not part of the PWM: data spaces are at most 2 bit times, the guard is 39. +static constexpr uint32_t GAP_TIME_US = 4 * BIT_TIME_US; /* KeeLoq Protocol Coded using information from datasheet for Microchip HCS301 KeeLow Code Hopping Encoder -Encoder - Hopping code is generated at random. +Encoder - Hopping code is not generated. Send the encrypted field from the caller. +Optional suffix bits may follow the 66-bit HCS301 word (same PWM coding). Decoder - Hopping code is ignored and not checked when received. Serial number of -transmitter and nutton command is decoded. +transmitter and button command is decoded. Extra bits after the word are returned +as suffix / suffix_bits when present. */ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { uint32_t out_data = 0x0; - ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32, data.address, data.command, - data.encrypted); + ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32 " suffix_bits=%u", data.address, + data.command, data.encrypted, data.suffix_bits); ESP_LOGV(TAG, "Send Keeloq: data bits (%d + %d)", NBITS_ENCRYPTED_DATA, NBITS_FIXED_DATA); // Preamble = '01' x 12 @@ -92,6 +97,17 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->mark(1 * BIT_TIME_US); dst->space(2 * BIT_TIME_US); + const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; + for (uint8_t i = 0; i < extra; i++) { + if (data.suffix & (1 << i)) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } + } + // Guard time at end of packet dst->space(39 * BIT_TIME_US); } @@ -103,10 +119,11 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { .command = 0, .repeat = false, .vlow = false, - + .suffix = 0, + .suffix_bits = 0, }; - if (src.size() != (NBITS_PREAMBLE + NBITS_DATA) * 2) { + if (src.size() < (NBITS_PREAMBLE + NBITS_DATA) * 2) { return {}; } @@ -182,11 +199,43 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { return {}; } + if (out.repeat) { + src.expect_space(2 * BIT_TIME_US); + } else { + src.expect_space(BIT_TIME_US); + } + + while (out.suffix_bits < NBITS_SUFFIX_MAX) { + bool one = false; + if (src.expect_mark(2 * BIT_TIME_US)) { + one = false; + } else if (src.expect_mark(BIT_TIME_US)) { + one = true; + } else { + break; + } + // The last bit's space is merged into the guard gap, or the capture ends at idle. + if (!src.expect_space(one ? 2 * BIT_TIME_US : BIT_TIME_US) && src.is_valid() && + !src.peek_space_at_least(GAP_TIME_US)) { + break; + } + if (one) { + out.suffix |= 1 << out.suffix_bits; + } + out.suffix_bits++; + } + // The suffix ends at a gap or at the end of the capture. PWM that goes on is not a suffix. + if (src.is_valid() && !src.peek_space_at_least(GAP_TIME_US)) { + out.suffix = 0; + out.suffix_bits = 0; + } + return out; } void KeeloqProtocol::dump(const KeeloqData &data) { - ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", + data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 335fbd164b..e391212e42 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -11,10 +11,11 @@ struct KeeloqData { uint8_t command; // Button Status S2-S1-S0-S3 bool repeat; // Repeated command bit bool vlow; // Battery status bit + uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word + uint8_t suffix_bits{0}; bool operator==(const KeeloqData &rhs) const { - // Treat 0x10 as a special, wildcard button press - // This allows us to match on just the address if wanted. + // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { return false; } @@ -31,21 +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) - - 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...); - 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 c2a487080b..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"); @@ -16,16 +19,21 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; // Max data bytes in packet (excluding checksum) constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); -void MirageProtocol::encode(RemoteTransmitData *dst, const MirageData &data) { +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.data.data(), data.data.size())); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, log_buf, log_len)); dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); dst->space(HEADER_SPACE_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; 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 958127ba66..ae6ed0a8e9 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,7 +13,14 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data); + 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); @@ -23,15 +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 { - MirageData data{}; - data.data = this->code_.value(x...); - MirageProtocol().encode(dst, data); - } -}; - } // 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/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/script/script.h b/esphome/components/script/script.h index 97b27af498..f00ad7f4af 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 3d46f0613e..147365251b 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -115,11 +115,7 @@ void SDS011Component::sds011_write_command_(const uint8_t *command_data) { } uint8_t SDS011Component::sds011_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional SDS011Component::check_byte_() const { diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 31c8e89af3..8d5103cbba 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 { ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); +static constexpr uint32_t STARTUP_DELAY_MS = 1000; + // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -251,17 +258,11 @@ void MR24HPC1Component::loop() { } } -// Calculate CRC check digit -static uint8_t get_frame_crc_sum(const uint8_t *data, int len) { - unsigned int crc_sum = 0; - for (int i = 0; i < len - 3; i++) { - crc_sum += data[i]; - } - return crc_sum & 0xff; -} +// Frames are at least 10 bytes, so the three trailing bytes can always be left out +static uint8_t get_frame_crc_sum(const uint8_t *data, size_t len) { return sum_bytes(data, len - 3); } // Check that the check digit is correct -static int get_frame_check_status(uint8_t *data, int len) { +static int get_frame_check_status(const uint8_t *data, size_t len) { uint8_t crc_sum = get_frame_crc_sum(data, len); uint8_t verified = data[len - 3]; return (verified == crc_sum) ? 1 : 0; @@ -686,6 +687,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e..23cecfc986 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); 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 2b404442a4..1440e07f76 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -672,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -1033,7 +1033,8 @@ async def setup_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") filters = await _build_chain_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 510ee94c6a..01949f45df 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -545,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); 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/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 0660e0f20e..a7959a0999 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -83,13 +83,7 @@ void SM300D2Sensor::update() { this->humidity_sensor_->publish_state(humidity); } -uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { - uint8_t sum = 0; - for (int i = 0; i < (SM300D2_RESPONSE_LENGTH - 1); i++) { - sum += *ptr++; - } - return sum; -} +uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); } void SM300D2Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SM300D2:"); 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 f6d1daa124..630d68b562 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,9 +8,20 @@ #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 +#if defined(USE_HOST) +#include +#include +#elif defined(USE_ZEPHYR) +#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4 +#include +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -119,25 +130,219 @@ 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); +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 +template static bool foreach_eligible_ipv6_if(F &&callback) { +#if defined(USE_HOST) + struct ifaddrs *ifaddr; + if (getifaddrs(&ifaddr) != 0) { + return false; + } + bool found_any = false; + for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) { + continue; + } + unsigned int idx = if_nametoindex(ifa->ifa_name); + if (idx == 0) { + continue; + } + found_any = true; + if (callback(idx)) { + break; + } + } + freeifaddrs(ifaddr); + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY) + (void) callback; + return false; +#elif defined(USE_ZEPHYR) + // Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API. + // NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below. + struct EligibleIfContext { + F *callback; + bool found_any = false; + bool done = false; + }; + EligibleIfContext ctx{&callback}; + net_if_foreach( + [](struct net_if *iface, void *user_data) { + auto *ctx = static_cast(user_data); + if (ctx->done) { + return; + } + if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) || + net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) { + return; + } + int idx = net_if_get_by_iface(iface); + if (idx <= 0) { + return; + } + ctx->found_any = true; + if ((*ctx->callback)(static_cast(idx))) { + ctx->done = true; + } + }, + &ctx); + if (!ctx.found_any) { + errno = EADDRNOTAVAIL; + } + return ctx.found_any; #else - return socket(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ + // 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)); + } + } + for (uint8_t index : indices) { + if (callback(index)) { + break; + } + } + if (indices.empty()) { + errno = EADDRNOTAVAIL; + } + return !indices.empty(); +#endif +} +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + +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 + if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) { +#else + if (inet_aton(ip_address, &ipv4mc) == 0) { +#endif + errno = EINVAL; + return false; + } +#ifdef USE_ZEPHYR + // Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has + // no plain "struct ip_mreq" at all (unlike POSIX/lwIP). + struct ip_mreqn imreq {}; + imreq.imr_address.s_addr = ESPHOME_INADDR_ANY; + imreq.imr_ifindex = 0; +#else + struct ip_mreq imreq {}; + imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; +#endif + imreq.imr_multiaddr = ipv4mc; + if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) { + return false; + } + if (if_index_out != nullptr) { + *if_index_out = 0; + } + return true; + } +#ifdef USE_SOCKET_IPV6 + struct ipv6_mreq imreq6 {}; +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#else + if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#endif + errno = EINVAL; + return false; + } +#else + ip6_addr_t ip6; + if (!inet6_aton(ip_address, &ip6)) { + errno = EINVAL; + return false; + } + memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif + // POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for + // other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected. + bool joined = false; + foreach_eligible_ipv6_if([&](unsigned int idx) { +#ifdef USE_ZEPHYR + // This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no + // IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names. + imreq6.ipv6mr_ifindex = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) { +#else + imreq6.ipv6mr_interface = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) { +#endif + joined = true; + return true; + } + return false; + }); + if (!joined) { + return false; + } + if (if_index_out != nullptr) { +#ifdef USE_ZEPHYR + *if_index_out = imreq6.ipv6mr_ifindex; +#else + *if_index_out = imreq6.ipv6mr_interface; +#endif + } + return true; +#else + errno = EINVAL; + return false; +#endif // USE_SOCKET_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 defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 - return socket_listen_loop_monitored(AF_INET6, type, protocol); +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { +#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + // Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing + // multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0. + (void) sock; + (void) if_index_in; + return true; #else - return socket_listen_loop_monitored(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} + uint32_t ifindex = if_index_in; + if (ifindex == 0) { + foreach_eligible_ipv6_if([&](unsigned int idx) { + ifindex = idx; + return true; + }); + if (ifindex == 0) { + errno = EADDRNOTAVAIL; + return false; + } + } + return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0; #endif +} +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9d7dedbbbf..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 @@ -94,6 +115,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. +/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that. std::unique_ptr socket_ip(int type, int protocol); /// Create a socket and monitor it for data in the main loop. @@ -104,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. @@ -111,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) { @@ -120,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 @@ -149,6 +196,27 @@ 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); +/// 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 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(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 +/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible +/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always +/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly. +/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr. +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0); +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + /// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or /// errno) on fd implementations and the failure the callbacks recorded on raw lwip. #ifdef USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 02282fef9c..c72210e932 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -52,13 +52,7 @@ ESPHOME_LOG_TAG(TAG, "sonoff_d1"); static constexpr size_t SONOFF_D1_ACK_SIZE = 7; static constexpr size_t SONOFF_D1_MAX_CMD_SIZE = 17; -uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { - uint8_t crc = 0; - for (size_t i = 2; i < len - 1; i++) { - crc += cmd[i]; - } - return crc; -} +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { return sum_bytes(cmd + 2, len - 3); } void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { // Update the checksum 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/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 4c537b75cb..6c780d07a9 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -10,12 +10,7 @@ ESPHOME_LOG_TAG(TAG, "systa_bus"); void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } -static bool checksum(std::span data) { - uint8_t csum = 0; - for (uint8_t i : data) - csum += i; - return csum == 0; -} +static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { uint8_t c; diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index 5e7f0ef29c..15111338b2 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -168,11 +168,9 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; // Calculate & set the checksum for the current internal state of the remote. // Stored the checksum value in the last byte. - for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) - checksum += remote_state[checksum_byte]; + const uint8_t checksum = sum_bytes(remote_state, TCL112_STATE_LENGTH - 1); if (checksum != remote_state[TCL112_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); return false; diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 35326d77d5..df9c1720ee 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -35,7 +35,7 @@ TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) BASE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(TcpUart), + cv.GenerateID(): cv.All(cv.declare_id(TcpUart), uart.mark_unclocked), cv.Required(CONF_PORT): cv.port, cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index 898aa94543..e19d76a64a 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_TEMPERATURE, @@ -20,11 +26,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_RED_CHANNEL = "red_channel" -CONF_GREEN_CHANNEL = "green_channel" -CONF_BLUE_CHANNEL = "blue_channel" -CONF_CLEAR_CHANNEL = "clear_channel" - tcs34725_ns = cg.esphome_ns.namespace("tcs34725") TCS34725Component = tcs34725_ns.class_( "TCS34725Component", cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index adf5670784..1d3813d59e 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -6,30 +6,30 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ -static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { - char *field_end; +static int get_field(char *dest, uint8_t *buf_start, uint8_t *buf_end, int sep, int max_len) { + uint8_t *field_end; int len; - field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); + field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); if (!field_end) return 0; len = field_end - buf_start; if (len >= max_len) return len; - strncpy(dest, buf_start, len); + memcpy(dest, buf_start, len); dest[len] = '\0'; return len; } /* TeleInfo methods */ -bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { +bool TeleInfo::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { int grp_len = grp_end - grp; + if (grp_len < this->checksum_area_end_) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = 0; - int i; - - for (i = 0; i < grp_len - checksum_area_end_; i++) - crc_tmp += grp[i]; + uint8_t crc_tmp = sum_bytes(grp, grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; @@ -46,7 +46,8 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { while (available() > 0 && j < 128) { j++; - received = read(); + if (!read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -87,9 +88,9 @@ void TeleInfo::loop() { state_ = END_FRAME_RECEIVED; break; case END_FRAME_RECEIVED: - char *buf_finger; - char *grp_end; - char *buf_end; + uint8_t *buf_finger; + uint8_t *grp_end; + uint8_t *buf_end; int field_len; buf_finger = buf_; @@ -116,7 +117,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -126,7 +127,7 @@ void TeleInfo::loop() { buf_finger += 1; /* Group len */ - grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); + grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; @@ -153,7 +154,7 @@ void TeleInfo::loop() { if (std::count(buf_finger, grp_end, separator_) == 2 && strcmp(tag_, "DATE") != 0 && separator_ != 0x20) { field_len = get_field(timestamp_, buf_finger, grp_end, separator_, MAX_TIMESTAMP_SIZE); if (!field_len || field_len >= MAX_TIMESTAMP_SIZE) { - ESP_LOGE(TAG, "Invalid timestamp for tag %s", timestamp_); + ESP_LOGE(TAG, "Invalid timestamp for tag %s", tag_); continue; } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index b1bf586e9c..39f0287411 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -33,7 +33,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { protected: int checksum_area_end_; int separator_; - char buf_[MAX_BUF_SIZE]; + uint8_t buf_[MAX_BUF_SIZE]; uint32_t buf_index_{0}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; @@ -45,7 +45,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { END_FRAME_RECEIVED, } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); - bool check_crc_(const char *grp, const char *grp_end); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; } // namespace esphome::teleinfo 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 e9c70f7a2e..959ce77c7a 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -208,7 +208,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3cc4dc7009..8a954b181e 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -39,7 +39,6 @@ from esphome.const import ( CONF_HEAT_MODE, CONF_HEAT_OVERRUN, CONF_HUMIDITY_SENSOR, - CONF_ID, CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, @@ -72,6 +71,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.helpers import cpp_string_escape from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" @@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") +ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") ON_BOOT_RESTORE_FROM = { "MEMORY": OnBootRestoreFrom.MEMORY, @@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset") PRESET_CONFIG_SCHEMA = cv.Schema( { + # Still accepted for existing configs; presets are flash table entries, not variables. cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), cv.Required(CONF_NAME): cv.string_strict, cv.Optional(CONF_MODE): validate_climate_mode, @@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), - cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET): cv.All( + cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) + ), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: - # Separate standard and custom presets, and build preset config variables - standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] - custom_presets: list[tuple[str, cg.MockObj]] = [] + # Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count. + standard_presets: list[cg.StructInitializer] = [] + custom_presets: list[cg.StructInitializer] = [] + nan = float("nan") + unset = cg.RawExpression("std::nullopt") for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] - standard_preset = None - if name.upper() in climate.CLIMATE_PRESETS: - standard_preset = climate.CLIMATE_PRESETS[name.upper()] - + low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW) + high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH) if two_points_available: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + temperatures = (nan, low, high) + else: + temperatures = (high if high is not None else low, nan, nan) + target = ThermostatClimateTargetTempConfig( + *(nan if t is None else t for t in temperatures), + preset_config.get(CONF_MODE, unset), + preset_config.get(CONF_FAN_MODE, unset), + preset_config.get(CONF_SWING_MODE, unset), + ) + if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None: + standard_presets.append( + cg.StructInitializer( + ThermostatPresetEntry, ("preset", standard), ("config", target) + ) ) else: - preset_target_config = None - - preset_target_variable = cg.new_variable( - preset_config[CONF_ID], preset_target_config - ) - - if CONF_MODE in preset_config: - cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) - - if CONF_FAN_MODE in preset_config: - cg.add( - preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) - ) - - if CONF_SWING_MODE in preset_config: - cg.add( - preset_target_variable.set_swing_mode( - preset_config[CONF_SWING_MODE] + custom_presets.append( + cg.StructInitializer( + ThermostatCustomPresetEntry, + ("name", cg.RawExpression(cpp_string_escape(name))), + ("config", target), ) ) - if standard_preset is not None: - standard_presets.append((standard_preset, preset_target_variable)) - else: - custom_presets.append((name, preset_target_variable)) - - # Build initializer list for standard presets if standard_presets: - cg.add( - var.set_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatPresetEntry"), - ("preset", preset), - ("config", preset_var), - ) - for preset, preset_var in standard_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_presets", ThermostatPresetEntry, standard_presets ) - - # Build initializer list for custom presets + cg.add(var.set_preset_config(table, len(standard_presets))) if custom_presets: - cg.add( - var.set_custom_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatCustomPresetEntry"), - ("name", cg.RawExpression(f'"{name}"')), - ("config", preset_var), - ) - for name, preset_var in custom_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets ) + cg.add(var.set_custom_preset_config(table, len(custom_presets))) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index abfe1b027a..5d24759c2d 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (const auto &entry : this->preset_config_) { - traits.add_supported_preset(entry.preset); + for (uint8_t i = 0; i < this->preset_count_; i++) { + traits.add_supported_preset(this->preset_entry_(i).preset); } // Custom presets are stored on Climate base class and wired via get_traits() @@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } } +ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const { + ThermostatPresetEntry entry; + progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry)); + return entry; +} + +ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const { + ThermostatCustomPresetEntry entry; + progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry)); + return entry; +} + void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { // Linear search through preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->preset_config_) { + optional config; + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); if (entry.preset == preset) { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire preset changed trigger @@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) { // Linear search through custom preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->custom_preset_config_) { + optional config; + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); // Compare first len chars, then verify entry.name ends there (same length) if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); if (this->change_preset_internal_(*config) || !this->has_custom_preset() || this->get_custom_preset() != custom_preset) { @@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { +void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) { this->custom_preset_config_ = presets; + this->custom_preset_count_ = count; // Populate Climate base class custom presets vector std::vector names; - names.reserve(presets.size()); - for (const auto &entry : this->custom_preset_config_) { - names.push_back(entry.name); + names.reserve(count); + for (uint8_t i = 0; i < count; i++) { + names.push_back(this->custom_preset_entry_(i).name); } this->set_supported_custom_presets(names); } @@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default; void ThermostatClimate::set_default_preset(const char *custom_preset) { // Find the preset in custom_preset_config_ and store pointer from there - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); if (strcmp(entry.name, custom_preset) == 0) { this->default_custom_preset_ = entry.name; return; @@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() { YESNO(this->supports_dehumidification_ || this->supports_humidification_), YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); - if (!this->preset_config_.empty()) { + if (this->preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (const auto &entry : this->preset_config_) { + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : ""); @@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() { } } - if (!this->custom_preset_config_.empty()) { + if (this->custom_preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); const auto *preset_name = entry.name; ESP_LOGCONFIG(TAG, " %s:%s", preset_name, (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) @@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() { } } -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) - : default_temperature(default_temperature) {} - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, - float default_temperature_high) - : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} - } // namespace esphome::thermostat diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index b7d46eae22..e9c0ae75fd 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -49,9 +49,21 @@ struct ThermostatClimateTimer { struct ThermostatClimateTargetTempConfig { public: - ThermostatClimateTargetTempConfig(); - ThermostatClimateTargetTempConfig(float default_temperature); - ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + constexpr ThermostatClimateTargetTempConfig() = default; + constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) + : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} + /// Full form used by the generated preset tables in flash. + constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low, + float default_temperature_high, optional mode, + optional fan_mode, + optional swing_mode) + : default_temperature(default_temperature), + default_temperature_low(default_temperature_low), + default_temperature_high(default_temperature_high), + fan_mode_(fan_mode), + swing_mode_(swing_mode), + mode_(mode) {} void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } @@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float cool_deadband_{NAN}; - float cool_overrun_{NAN}; - float heat_deadband_{NAN}; - float heat_overrun_{NAN}; optional fan_mode_{}; optional swing_mode_{}; optional mode_{}; @@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } - void set_custom_preset_config(std::initializer_list presets); + /// `presets` is a PROGMEM table that outlives the thermostat. + void set_preset_config(const PresetEntry *presets, uint8_t count) { + this->preset_config_ = presets; + this->preset_count_ = count; + } + void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count); Trigger<> *get_cool_action_trigger(); Trigger<> *get_supplemental_cool_action_trigger(); @@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component { bool humidification_required_(); void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); + /// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields. + PresetEntry preset_entry_(uint8_t index) const; + CustomPresetEntry custom_preset_entry_(uint8_t index) const; /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; @@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component { /// state will attempt to be restored if possible OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + uint8_t preset_count_{0}; + uint8_t custom_preset_count_{0}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Climate action timers std::array timer_{}; - /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - FixedVector preset_config_{}; - /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - FixedVector custom_preset_config_{}; + /// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries + const PresetEntry *preset_config_{nullptr}; + /// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries + const CustomPresetEntry *custom_preset_config_{nullptr}; private: - /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + /// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_) const char *default_custom_preset_{nullptr}; }; diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a017109e7d..2ad3cd9c31 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -455,6 +457,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 8510f18769..077dd03ccc 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,11 +1,104 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. +"""Shared TMP102 types and configuration validation.""" -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE +import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType -""" +CONF_TMP102_ID = "tmp102_id" DOMAIN = "tmp102" + +tmp102_ns = cg.esphome_ns.namespace("tmp102") +TMP102Component = tmp102_ns.class_( + "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_EXTENDED_MODE = "extended_mode" +CONF_ONE_SHOT_MODE = "one_shot_mode" +CONF_TEMPERATURE_HIGH = "temperature_high" +CONF_TEMPERATURE_LOW = "temperature_low" +CONF_ALERT_POLARITY = "alert_polarity" +CONF_THERMOSTAT_MODE = "thermostat_mode" +CONF_FAULT_QUEUE = "fault_queue" + +CONVERSION_RATES = {"0.25Hz": 0, "1Hz": 1, "4Hz": 2, "8Hz": 3} +THERMOSTAT_MODES = {"comparator": 0, "interrupt": 1} +ALERT_POLARITIES = {"active_low": 0, "active_high": 1} +FAULT_QUEUE_BITS = {1: 0, 2: 1, 4: 2, 6: 3} + +TMP102_CONVERSION_FACTOR = 0.0625 +TMP102_DEFAULT_THIGH = 80.0 +TMP102_DEFAULT_TLOW = 75.0 + + +def encode_temperature(temperature: float, extended: bool) -> int: + """Encode a temperature as a TMP102 limit-register word.""" + steps = round(temperature / TMP102_CONVERSION_FACTOR) + return (steps << (3 if extended else 4)) & 0xFFFF + + +def build_configuration(config: ConfigType) -> int: + """Build the writable TMP102 configuration-register bits.""" + value = CONVERSION_RATES[config.get(CONF_CONVERSION_RATE, "4Hz")] << 6 + value |= FAULT_QUEUE_BITS[config.get(CONF_FAULT_QUEUE, 1)] << 11 + value |= ALERT_POLARITIES[config.get(CONF_ALERT_POLARITY, "active_low")] << 10 + value |= THERMOSTAT_MODES[config.get(CONF_THERMOSTAT_MODE, "comparator")] << 9 + if config.get(CONF_ONE_SHOT_MODE, False): + value |= 1 << 8 + if config.get(CONF_EXTENDED_MODE, False): + value |= 1 << 4 + return value + + +def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: + extended = config.get(CONF_EXTENDED_MODE, False) + t_min = -55.0 + t_max = 150.0 if extended else 127.9375 + high = config.get(CONF_TEMPERATURE_HIGH) + low = config.get(CONF_TEMPERATURE_LOW) + for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): + if value is not None and not t_min <= value <= t_max: + raise cv.Invalid( + f"{key} ({value}°C) out of range for " + f"{'extended' if extended else 'normal'} mode " + f"({t_min}°C to {t_max}°C)", + [key], + ) + + if high is not None and low is not None and low > high: + raise cv.Invalid( + f"low limit ({low}°C) must be <= high limit ({high}°C)", + [CONF_TEMPERATURE_LOW], + ) + if high is not None and low is None and high < TMP102_DEFAULT_TLOW: + raise cv.Invalid( + f"high limit ({high}°C) is below the chip power-up TLOW default " + f"({TMP102_DEFAULT_TLOW}°C). Set temperature_low explicitly.", + [CONF_TEMPERATURE_HIGH], + ) + if low is not None and high is None and low > TMP102_DEFAULT_THIGH: + raise cv.Invalid( + f"low limit ({low}°C) is above the chip power-up THIGH default " + f"({TMP102_DEFAULT_THIGH}°C). Set temperature_high explicitly.", + [CONF_TEMPERATURE_LOW], + ) + return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 0000000000..eb23206b98 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index eba0d3c026..fbfed7444d 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -1,15 +1,8 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. - -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +"""TMP102 temperature sensor and optional hardware configuration.""" import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -18,15 +11,42 @@ from esphome.const import ( ) from esphome.types import ConfigType +from . import ( + ALERT_POLARITIES, + CONF_ALERT_POLARITY, + CONF_EXTENDED_MODE, + CONF_FAULT_QUEUE, + CONF_ONE_SHOT_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_THERMOSTAT_MODE, + CONVERSION_RATES, + FAULT_QUEUE_BITS, + THERMOSTAT_MODES, + TMP102_DEFAULT_THIGH, + TMP102_DEFAULT_TLOW, + TMP102Component, + build_configuration, + encode_temperature, + validate_tmp102_thresholds, +) + CODEOWNERS = ["@timsavage"] DEPENDENCIES = ["i2c"] -tmp102_ns = cg.esphome_ns.namespace("tmp102") -TMP102Component = tmp102_ns.class_( - "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor -) +# No defaults here: absence of all advanced options preserves upstream's read-only behavior and firmware size. +_ADVANCED_SCHEMA = { + cv.Optional(CONF_EXTENDED_MODE): cv.boolean, + cv.Optional(CONF_CONVERSION_RATE): cv.enum(CONVERSION_RATES), + cv.Optional(CONF_ONE_SHOT_MODE): cv.boolean, + cv.Optional(CONF_ALERT_POLARITY): cv.enum(ALERT_POLARITIES), + cv.Optional(CONF_THERMOSTAT_MODE): cv.enum(THERMOSTAT_MODES), + cv.Optional(CONF_FAULT_QUEUE): cv.enum(FAULT_QUEUE_BITS, int=True), + cv.Optional(CONF_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TEMPERATURE_LOW): cv.temperature, +} -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( TMP102Component, unit_of_measurement=UNIT_CELSIUS, @@ -36,6 +56,8 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x48)) + .extend(_ADVANCED_SCHEMA), + validate_tmp102_thresholds, ) @@ -43,3 +65,18 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + + if not any(key in config for key in _ADVANCED_SCHEMA): + return + + extended = config.get(CONF_EXTENDED_MODE, False) + high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) + low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) + cg.add_define("USE_TMP102_CONFIGURE") + cg.add( + var.set_configuration( + build_configuration(config), + encode_temperature(high, extended), + encode_temperature(low, extended), + ) + ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index d851bd925f..09b29dd7ef 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -8,13 +8,36 @@ ESPHOME_LOG_TAG(TAG, "tmp102"); static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; -static const uint8_t TMP102_ADDRESS = 0x48; -static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; -static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; -static const uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; -static const uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; +static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) +static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE +static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; +static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; -static const float TMP102_CONVERSION_FACTOR = 0.0625; +static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; +static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; +static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; +static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; +static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; +static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; +static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; + +static float decode_temperature(uint16_t raw, bool extended) { + return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; +} + +void TMP102Component::setup() { + if (!this->configure_) + return; + if (!this->write_configuration_()) + this->mark_failed(); +} +#endif void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); @@ -24,17 +47,55 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif +#ifdef USE_TMP102_CONFIGURE + if (!this->configure_) + return; + const uint8_t rate = (this->config_ >> 6) & 0x03; + const LogString *rate_str = rate == 0 ? LOG_STR("0.25Hz") + : rate == 1 ? LOG_STR("1Hz") + : rate == 2 ? LOG_STR("4Hz") + : LOG_STR("8Hz"); + const uint8_t fault_bits = (this->config_ >> 11) & 0x03; + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + ESP_LOGCONFIG( + TAG, + " Extended Mode: %s\n" + " One-Shot Mode: %s\n" + " Conversion Rate: %s\n" + " Thermostat Mode: %s\n" + " Alert Polarity: %s\n" + " Fault Queue: %u\n" + " Temperature High: %.4f°C\n" + " Temperature Low: %.4f°C", + YESNO(extended), YESNO(this->config_ & TMP102_SHUTDOWN_BIT), LOG_STR_ARG(rate_str), + this->config_ & TMP102_THERMOSTAT_MODE_BIT ? LOG_STR_LITERAL("Interrupt") : LOG_STR_LITERAL("Comparator"), + this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), + fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), + decode_temperature(this->low_limit_, extended)); + if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { + ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); + } +#endif } void TMP102Component::update() { +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->update_configured_(); + return; + } +#endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -43,8 +104,107 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::update_configured_() { + if (!this->check_configuration_()) + return; + if (this->config_ & TMP102_SHUTDOWN_BIT) { + if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { + this->set_warning_and_invalidate_alert_(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); + } else { + this->read_temperature_(); + } +} + +bool TMP102Component::write_register_(uint8_t reg, uint16_t value) { + uint8_t frame[3] = {reg, static_cast(value >> 8), static_cast(value)}; + if (this->write(frame, 3) == i2c::ERROR_OK) + return true; + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return false; +} + +bool TMP102Component::write_configuration_() { + return this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_) && + this->write_register_(TMP102_REGISTER_HIGH_LIMIT, this->high_limit_) && + this->write_register_(TMP102_REGISTER_LOW_LIMIT, this->low_limit_); +} + +bool TMP102Component::check_configuration_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + return false; + } + bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); + // A chip reset is invisible in the configuration register when it matches the reset default; check the limits. + if (matches && + (this->config_ & TMP102_CONFIGURATION_MASK) == (TMP102_RESET_CONFIGURATION & TMP102_CONFIGURATION_MASK)) { + uint16_t high, low; + if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || + !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { + this->set_warning_and_invalidate_alert_(); + return false; + } + matches = high == this->high_limit_ && low == this->low_limit_; + } + if (matches) + return true; + + ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); + this->set_warning_and_invalidate_alert_(); + if (!this->write_configuration_()) { + ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + } + return false; +} + +void TMP102Component::read_temperature_() { + uint16_t raw; + if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { + this->set_warning_and_invalidate_alert_(); + return; + } + const float temperature = decode_temperature(raw, raw & 0x0001); + ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); + this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif + this->status_clear_warning(); +} +#endif + +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index f9eda32e98..caf75e19be 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -1,15 +1,57 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" +#include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#include namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif +#ifdef USE_TMP102_CONFIGURE + void setup() override; + void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { + this->config_ = config; + this->high_limit_ = high_limit; + this->low_limit_ = low_limit; + this->configure_ = true; + } +#endif void dump_config() override; void update() override; + + protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif +#ifdef USE_TMP102_CONFIGURE + bool write_register_(uint8_t reg, uint16_t value); + bool write_configuration_(); + bool check_configuration_(); + void read_temperature_(); + void update_configured_(); + + uint16_t config_{0}; + uint16_t high_limit_{0}; + uint16_t low_limit_{0}; + bool configure_{false}; +#endif }; } // namespace esphome::tmp102 diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 57361a4a58..406a1bc55d 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1052,8 +1052,6 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co } bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { - uint8_t checksum = 0; - switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: @@ -1065,10 +1063,7 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { break; case RAC_PT1411HWRU_MESSAGE_HEADER1: - for (uint8_t i = 0; i < RAC_PT1411HWRU_MESSAGE_LENGTH - 1; i++) { - checksum += message[i]; - } - if (checksum == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { + if (sum_bytes(message, RAC_PT1411HWRU_MESSAGE_LENGTH - 1) == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { return true; } break; diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index c6316f59fc..025b97ee72 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -152,9 +152,7 @@ bool Tuya::validate_message_() { // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 uint8_t rx_checksum = new_byte; - uint8_t calc_checksum = 0; - for (uint32_t i = 0; i < 6 + length; i++) - calc_checksum += data[i]; + const uint8_t calc_checksum = sum_bytes(data, 6 + length); if (rx_checksum != calc_checksum) { ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); @@ -531,10 +529,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { if (!command.payload.empty()) this->write_array(command.payload.data(), command.payload.size()); - uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + const uint8_t header_sum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + this->write_byte(sum_bytes(command.payload.data(), command.payload.size(), header_sum)); } void Tuya::process_command_queue_() { diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2250b9d508..d1ac806b5f 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from logging import getLogger import math import re @@ -136,6 +137,10 @@ def validate_raw_data(value): ) +# Lengths are stored as uint16_t; reject longer payloads at validation. +validate_raw_payload = cv.All(validate_raw_data, cv.Length(max=65535)) + + def validate_rx_pin(value): value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: @@ -444,6 +449,34 @@ UART_DEVICE_SCHEMA = cv.Schema( KEY_UART_DEVICES = "uart_devices" +@dataclass +class UARTData: + # UARTs whose received bytes do not keep the timing of a serial line. + unclocked: list[ID] = field(default_factory=list) + + +def _get_data() -> UARTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def mark_unclocked(uart_id: ID) -> ID: + """Record that the bytes of uart_id do not arrive with the timing of a serial line, e.g. over TCP or USB. + + A gap between them says nothing about where a frame ends. Use it on the UART's declared id, + cv.All(cv.declare_id(MyUart), uart.mark_unclocked), so that every to_code sees it. + """ + _get_data().unclocked.append(uart_id) + return uart_id + + +def is_unclocked(uart_id: ID) -> bool: + """Return True if uart_id was passed to mark_unclocked().""" + # By name: a generated id is named only after the schemas ran. + return any(str(uart) == str(uart_id) for uart in _get_data().unclocked) + + def final_validate_device_schema( name: str, *, @@ -567,7 +600,7 @@ async def register_uart_device(var, config): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_raw_payload), }, key=CONF_DATA, ), @@ -576,18 +609,9 @@ async def register_uart_device(var, config): async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" + ) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index e5a9fa7c7b..b134c8af9f 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -8,36 +8,15 @@ namespace esphome::uart { template class UARTWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->code_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: use pointer and length - this->parent_->write_array(this->code_.data, static_cast(this->len_)); + if (this->data_.is_static()) { + this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); } else { - // Template mode: call function - auto val = this->code_.func(x...); - this->parent_->write_array(val); + this->parent_->write_array(this->data_.value(x...)); } } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } code_; }; } // namespace esphome::uart diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 95fe21271d..821d8264dc 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv from esphome.const import CONF_DATA -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload CODEOWNERS = ["@ssieb"] @@ -17,7 +17,7 @@ CONFIG_SCHEMA = ( button.button_schema(UARTButton) .extend( { - cv.Required(CONF_DATA): validate_raw_data, + cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -31,6 +31,4 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data(cg.ArrayInitializer(*data))) + cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index b55207cb46..99b5ee548d 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -7,7 +7,7 @@ ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); - this->write_array(this->data_.data(), this->data_.size()); + this->write_array_progmem(this->data_, this->data_len_); } void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 47f45d4899..903f945fd3 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -4,20 +4,21 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/button/button.h" -#include - namespace esphome::uart { class UARTButton final : public button::Button, public UARTDevice, public Component { public: - void set_data(std::vector &&data) { this->data_ = std::move(data); } - void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + void set_data(const uint8_t *data, uint16_t len) { + this->data_ = data; + this->data_len_ = len; + } void dump_config() override; protected: void press_action() override; - std::vector data_; + const uint8_t *data_{nullptr}; + uint16_t data_len_{0}; }; } // namespace esphome::uart diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 0000000000..d80dbf1a85 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 290bbed5d3..3288ceeee2 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import switch, uart import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_SEND_EVERY -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload DEPENDENCIES = ["uart"] @@ -18,11 +18,11 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_DATA): cv.Any( - validate_raw_data, + validate_raw_payload, cv.Schema( { - cv.Optional(CONF_TURN_OFF): validate_raw_data, - cv.Optional(CONF_TURN_ON): validate_raw_data, + cv.Optional(CONF_TURN_OFF): validate_raw_payload, + cv.Optional(CONF_TURN_ON): validate_raw_payload, } ), ), @@ -40,20 +40,16 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, dict): - if data_on := data.get(CONF_TURN_ON): - if isinstance(data_on, bytes): - data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) - if data_off := data.get(CONF_TURN_OFF): - if isinstance(data_off, bytes): - data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) - else: - data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + single_state = not isinstance(data, dict) + if single_state: + data = {CONF_TURN_ON: data} + for key, setter in ( + (CONF_TURN_ON, var.set_data_on), + (CONF_TURN_OFF, var.set_data_off), + ): + if payload := data.get(key): + cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) + if single_state: cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 6b6aa273f7..1af497ffcf 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -17,13 +17,13 @@ void UARTSwitch::loop() { } void UARTSwitch::write_command_(bool state) { - if (state && !this->data_on_.empty()) { + if (state && this->data_on_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); - this->write_array(this->data_on_.data(), this->data_on_.size()); + this->write_array_progmem(this->data_on_, this->data_on_len_); } - if (!state && !this->data_off_.empty()) { + if (!state && this->data_off_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); - this->write_array(this->data_off_.data(), this->data_off_.size()); + this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index c924c7d4e5..96d8833d7a 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -5,7 +5,6 @@ #include "esphome/components/switch/switch.h" #include -#include namespace esphome::uart { @@ -13,10 +12,14 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo public: void loop() override; - void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } - void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } - void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } - void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_data_on(const uint8_t *data, uint16_t len) { + this->data_on_ = data; + this->data_on_len_ = len; + } + void set_data_off(const uint8_t *data, uint16_t len) { + this->data_off_ = data; + this->data_off_len_ = len; + } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } @@ -25,8 +28,10 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo protected: void write_command_(bool state); void write_state(bool state) override; - std::vector data_on_; - std::vector data_off_; + const uint8_t *data_on_{nullptr}; + const uint8_t *data_off_{nullptr}; + uint16_t data_on_len_{0}; + uint16_t data_off_len_{0}; bool single_state_{false}; uint32_t send_every_; uint32_t last_transmission_; diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 20f6385b6a..56b8e6401e 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index d1c6773363..7a34a20583 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -25,6 +25,7 @@ class UARTDevice { size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array_progmem(const uint8_t *data, size_t len) { this->parent_->write_array_progmem(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { this->parent_->write_array(data.data(), data.size()); diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 82bc592702..e77e37cd08 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -46,4 +46,17 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } +#ifdef USE_ESP8266 +void UARTComponent::write_array_progmem(const uint8_t *data, size_t len) { + uint8_t buf[32]; + while (len > 0) { + const size_t chunk = std::min(len, sizeof(buf)); + progmem_memcpy(buf, data, chunk); + this->write_array(buf, chunk); + data += chunk; + len -= chunk; + } +} +#endif + } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 2543cb5476..14709d4490 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -18,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER @@ -62,6 +65,13 @@ class UARTComponent { // @param len Length of the array. virtual void write_array(const uint8_t *data, size_t len) = 0; + // Writes bytes that may live in PROGMEM; ESP8266 cannot read flash bytewise, so it copies chunks. +#ifdef USE_ESP8266 + void write_array_progmem(const uint8_t *data, size_t len); +#else + void write_array_progmem(const uint8_t *data, size_t len) { this->write_array(data, len); } +#endif + // Reads a single byte from the UART bus. // @param data Pointer to the byte where the read data will be stored. // @return True if a byte was successfully read, false otherwise. diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index a32677b3da..0819a1bfb0 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -16,6 +16,12 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +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) uint32_t ESP8266UartComponent::get_config() { @@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -239,23 +250,39 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } } } // A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles @@ -288,8 +315,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { if (arg->stop_bits_ == 2) arg->wait_(&wait, start, bit_time); - arg->rx_buffer_[arg->rx_in_pos_] = rec; - arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -299,6 +325,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -350,28 +397,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 0098e752b2..3d4a2ba85b 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 9e2f273f47..7fb704536d 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 5a77bf26d9..802a88ba80 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; 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/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index e1fcef107d..1846958e59 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass + +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 +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -16,6 +20,23 @@ UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component CONF_UFM01_ID = "ufm01_id" + +@dataclass +class UFM01Data: + clear_action_used: bool = False + + +def _get_data() -> UFM01Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UFM01Data() + return CORE.data[DOMAIN] + + +def _note_clear_action(config: ConfigType) -> ConfigType: + _get_data().clear_action_used = True + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -26,6 +47,13 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(UFM01Component), + }, + _note_clear_action, +) + FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "ufm01", require_tx=True, @@ -41,3 +69,12 @@ 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) + if _get_data().clear_action_used: + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + +automation.register_apply_action( + "ufm01.clear_accumulated_flow", + CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA, + automation.ApplyCall("request_clear_accumulated_flow()"), +) diff --git a/esphome/components/ufm01/text_sensor.py b/esphome/components/ufm01/text_sensor.py new file mode 100644 index 0000000000..dcb5d3294c --- /dev/null +++ b/esphome/components/ufm01/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP, ICON_FINGERPRINT +from esphome.types import ConfigType + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_METER_ID = "meter_id" +CONF_SOFTWARE_VERSION = "software_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_METER_ID): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_FINGERPRINT + ), + cv.Optional(CONF_SOFTWARE_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UFM01_ID]) + sensors = text_sensor.sub_text_sensors(config) + if await sensors(CONF_METER_ID, hub.set_meter_id_text_sensor) is not None: + cg.add_define("USE_UFM01_METER_ID") + if ( + await sensors(CONF_SOFTWARE_VERSION, hub.set_software_version_text_sensor) + is not None + ): + cg.add_define("USE_UFM01_SOFTWARE_VERSION") diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index cd4388372c..47ff2165f8 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -1,8 +1,9 @@ #include "ufm01.h" -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include #include @@ -13,6 +14,7 @@ ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t CLEAR_QUEUE_TIMEOUT_MS = 15000; static constexpr uint32_t STARTUP_DELAY_MS = 2000; static constexpr uint32_t POST_RESET_DELAY_MS = 2000; static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; @@ -20,23 +22,40 @@ static constexpr uint32_t STARTUP_RETRY_MS = 3000; static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; static constexpr uint32_t ACTIVE_STALE_MS = 5000; static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t SOFTWARE_VERSION_READ_TIMEOUT_MS = 2000; +static constexpr uint32_t SOFTWARE_VERSION_RETRY_MS = 30000; +static constexpr uint8_t SOFTWARE_VERSION_MAX_ATTEMPTS = 3; static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; +// After this many consecutive passive poll failures, re-run the reset/startup sequence +static constexpr uint8_t PASSIVE_FAIL_ESCALATE_COUNT = 8; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr std::array READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr std::array GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_DEVICE_ID_INDEX = 2; static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr size_t PASSIVE_WITH_ID_DEVICE_ID_INDEX = 2; +static constexpr uint8_t PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX = 22; +static constexpr uint8_t PASSIVE_WITH_ID_TEMP_FLAG_INDEX = 31; +static constexpr uint8_t PASSIVE_WITH_ID_ST1_INDEX = 35; +static constexpr size_t PASSIVE_WITH_ID_CHECKSUM_INDEX = 37; +static constexpr size_t PASSIVE_WITH_ID_STOP_INDEX = 38; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; @@ -58,8 +77,40 @@ static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; +static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "software version read reuses passive_frame_"); +static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; +static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; + static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { + bool all_zero = true; + for (size_t i = 0; i < len; ++i) { + const uint8_t lo = data[i] & 0x0F; + const uint8_t hi = data[i] >> 4; + if (lo > 9 || hi > 9) + return false; + const size_t pos = (len - 1 - i) * 2; + if (hi != 0) + all_zero = false; + out[pos] = static_cast('0' + hi); + if (lo != 0) + all_zero = false; + out[pos + 1] = static_cast('0' + lo); + } + out[len * 2] = '\0'; + return !all_zero; +} + +static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { + if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; +} + static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { if (data[index] == expected) return true; @@ -68,9 +119,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp } static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { - uint8_t sum = 0; - for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) - sum += data[i]; + const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX); return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && @@ -83,31 +132,45 @@ static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += data[i]; - return data[21] == (sum & 0xFF); + return data[21] == sum_bytes(data, 21); } -static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { +#ifdef USE_UFM01_METER_ID +static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH_ID_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || + data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; +} +#endif + +// Start index of each run in a passive reply; each run maps to the matching run of the active frame +struct PassiveLayout { + uint8_t acc_flow; // flag + 6 bytes, active 8..14 + uint8_t instant_flow; // flag + 5 bytes, active 15..20 + uint8_t temperature; // flag + 3 bytes, active 24..27 + uint8_t status; // ST1, ST2, checksum, active 28..30 +}; +static constexpr PassiveLayout PASSIVE_NO_ID_LAYOUT{2, 9, 15, 19}; +#ifdef USE_UFM01_METER_ID +static constexpr PassiveLayout PASSIVE_WITH_ID_LAYOUT{PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_TEMP_FLAG_INDEX, PASSIVE_WITH_ID_ST1_INDEX}; +#endif + +static void passive_to_active_frame(const uint8_t *passive, const PassiveLayout &layout, uint8_t active[FRAME_SIZE]) { std::memset(active, 0, FRAME_SIZE); active[0] = FRAME_START_BYTE_1; active[1] = FRAME_START_BYTE_2; - active[7] = 0x01; - active[8] = passive[2]; - for (size_t i = 0; i < 6; ++i) - active[9 + i] = passive[3 + i]; - active[15] = passive[9]; - for (size_t i = 0; i < 5; ++i) - active[16 + i] = passive[10 + i]; - active[21] = FRAME_FLAG_RESERVED_SECTION; - active[24] = passive[15]; - for (size_t i = 0; i < 3; ++i) - active[25 + i] = passive[16 + i]; - active[28] = passive[19]; - active[29] = passive[20]; - active[30] = passive[21]; - active[31] = FRAME_STOP_BYTE; + std::copy_n(&passive[layout.acc_flow], 7, &active[FRAME_ACC_FLOW_FLAG_INDEX]); + std::copy_n(&passive[layout.instant_flow], 6, &active[FRAME_INDEX_INSTANT_FLOW_FLAG]); + active[FRAME_INDEX_RESERVED_SECTION] = FRAME_FLAG_RESERVED_SECTION; + std::copy_n(&passive[layout.temperature], 4, &active[FRAME_INDEX_TEMP_FLAG]); + std::copy_n(&passive[layout.status], 3, &active[FRAME_ST1_INDEX]); + active[FRAME_STOP_INDEX] = FRAME_STOP_BYTE; } static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { @@ -123,8 +186,10 @@ static float read_flow(const uint8_t data[FRAME_SIZE]) { M3_PER_L; } +static constexpr size_t LOG_HEX_MAX_SIZE = std::max(FRAME_SIZE, PASSIVE_FRAME_MAX_SIZE); + static void log_hex(const uint8_t *data, size_t len) { - char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + char hex_buf[format_hex_pretty_size(LOG_HEX_MAX_SIZE)]; ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } @@ -162,7 +227,6 @@ void UFM01Component::send_command_no_wait_(const std::array &command this->flush(); } -// Drains whatever is currently in the RX buffer, looking for a command ACK. bool UFM01Component::consume_ack_() { while (this->available()) { uint8_t byte; @@ -175,29 +239,65 @@ bool UFM01Component::consume_ack_() { return false; } -bool UFM01Component::send_command_(const std::array &command) { - this->send_command_no_wait_(command); - const uint32_t start = millis(); - while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->consume_ack_()) - return true; - delay(1); - } - return false; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +bool UFM01Component::can_start_clear_action_() const { + return +#ifdef USE_UFM01_SOFTWARE_VERSION + !this->software_version_read_pending_ && +#endif + (this->operating_mode_ == OperatingMode::ACTIVE_STREAM || + (this->operating_mode_ == OperatingMode::PASSIVE_POLL && !this->passive_read_pending_)); } -bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } +void UFM01Component::request_clear_accumulated_flow() { + if (this->clear_pending_) { + ESP_LOGD(TAG, "Clear accumulated flow already pending"); + return; + } + this->clear_pending_ = true; + this->pending_clear_sent_ = false; + this->pending_clear_start_ms_ = App.get_loop_component_start_time(); +} -bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } +void UFM01Component::finish_pending_clear_() { + this->clear_pending_ = false; + this->pending_clear_sent_ = false; +} -bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } +void UFM01Component::loop_pending_clear_action_(uint32_t now) { + if (!this->clear_pending_) + return; + if (!this->pending_clear_sent_) { + if (!this->can_start_clear_action_()) { + if (now - this->pending_clear_start_ms_ < CLEAR_QUEUE_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow timed out waiting for an idle UART"); + this->finish_pending_clear_(); + return; + } + this->pending_clear_start_ms_ = now; + this->pending_clear_sent_ = true; + this->send_command_no_wait_(CLEAR_ACCUMULATED_FLOW); + return; + } + if (this->consume_ack_()) { + ESP_LOGI(TAG, "Clear accumulated flow acknowledged"); + this->finish_pending_clear_(); + return; + } + if (now - this->pending_clear_start_ms_ < COMMAND_ACK_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow not acknowledged"); + this->finish_pending_clear_(); +} +#endif // USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); this->startup_wait_ms_ = STARTUP_DELAY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, App.get_loop_component_start_time()); } void UFM01Component::dump_config() { @@ -213,9 +313,123 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif +#ifdef USE_UFM01_METER_ID + LOG_TEXT_SENSOR(" ", "Meter ID", this->meter_id_text_sensor_); +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + LOG_TEXT_SENSOR(" ", "Software Version", this->software_version_text_sensor_); +#endif } -void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { +// The binary sensors keep their last state while readings are stale; only flow and temperature go unknown +void UFM01Component::publish_stale_flow_and_temperature_() { +#ifdef USE_SENSOR + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); +#endif +} + +#ifdef USE_UFM01_METER_ID +void UFM01Component::publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]) { + if (this->meter_id_text_sensor_ == nullptr || this->meter_id_published_) + return; + + char meter_id_str[DEVICE_ID_STRING_LENGTH + 1]; + if (!bcd_to_digits(&data[FRAME_DEVICE_ID_INDEX], DEVICE_ID_LENGTH, meter_id_str)) { + if (!this->meter_id_invalid_logged_) { + ESP_LOGW(TAG, "Meter ID in frame is zero or not BCD, leaving the sensor unknown"); + this->meter_id_invalid_logged_ = true; + } + return; + } + + this->meter_id_text_sensor_->publish_state(meter_id_str); + this->meter_id_published_ = true; + ESP_LOGI(TAG, "UFM-01 meter ID: %s", meter_id_str); +} +#endif + +#ifdef USE_UFM01_SOFTWARE_VERSION +void UFM01Component::start_software_version_read_(uint32_t now) { + this->send_command_no_wait_(GET_SOFTWARE_VERSION); + this->passive_index_ = 0; + this->passive_start_ms_ = now; + this->software_version_read_pending_ = true; +} + +ReadResult UFM01Component::continue_software_version_read_(uint32_t now) { + while (this->available() && this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + // The tail of an in-flight active-stream frame can arrive before the reply + if (this->passive_index_ == 0 && byte != COMMAND_ACK) + continue; + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + if (now - this->passive_start_ms_ < SOFTWARE_VERSION_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "software version read timeout (%u/%u bytes)", this->passive_index_, + static_cast(SOFTWARE_VERSION_RESPONSE_SIZE)); + return ReadResult::READ_RESULT_FAILURE; + } + + if (!validate_software_version_response(this->passive_frame_)) { + log_hex(this->passive_frame_, SOFTWARE_VERSION_RESPONSE_SIZE); + ESP_LOGW(TAG, "invalid software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_) { + char version_str[SOFTWARE_VERSION_STRING_LENGTH + 1]; + if (!bcd_to_digits(&this->passive_frame_[1], SOFTWARE_VERSION_LENGTH, version_str)) { + ESP_LOGW(TAG, "invalid BCD data in software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + this->software_version_text_sensor_->publish_state(version_str); + this->software_version_published_ = true; + ESP_LOGI(TAG, "UFM-01 software version: %s", version_str); + } + + return ReadResult::READ_RESULT_SUCCESS; +} + +void UFM01Component::finish_software_version_read_(ReadResult result, uint32_t now) { + this->software_version_read_pending_ = false; + this->last_software_version_attempt_ms_ = now; + if (result == ReadResult::READ_RESULT_FAILURE && + ++this->software_version_failures_ == SOFTWARE_VERSION_MAX_ATTEMPTS) { + ESP_LOGW(TAG, "Software version not read after %u attempts, giving up", SOFTWARE_VERSION_MAX_ATTEMPTS); + } +} + +// Returns true while a software version read owns the UART, so the caller skips its own work +bool UFM01Component::service_software_version_(uint32_t now) { + if (this->software_version_read_pending_) { + const ReadResult result = this->continue_software_version_read_(now); + if (result != ReadResult::READ_RESULT_PENDING) + this->finish_software_version_read_(result, now); + return true; + } + if (this->software_version_text_sensor_ == nullptr || this->software_version_published_ || + this->software_version_failures_ >= SOFTWARE_VERSION_MAX_ATTEMPTS || this->passive_read_pending_) + return false; + if (this->last_software_version_attempt_ms_ != 0 && + now - this->last_software_version_attempt_ms_ < SOFTWARE_VERSION_RETRY_MS) + return false; + this->start_software_version_read_(now); + return true; +} +#endif // USE_UFM01_SOFTWARE_VERSION + +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) { +#ifdef USE_UFM01_METER_ID + this->publish_meter_id_from_frame_(data); +#endif bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -234,10 +448,7 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); if (empty_tube) { - if (this->flow_sensor_ != nullptr) - this->flow_sensor_->publish_state(NAN); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(NAN); + this->publish_stale_flow_and_temperature_(); } else { if (this->flow_sensor_ != nullptr) this->flow_sensor_->publish_state(read_flow(data)); @@ -245,12 +456,12 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif - this->last_valid_frame_ms_ = millis(); + this->last_valid_frame_ms_ = now; this->status_clear_warning(); this->status_clear_error(); } -bool UFM01Component::process_active_stream_() { +bool UFM01Component::process_active_stream_(uint32_t now) { bool got_valid_frame = false; while (this->available()) { @@ -269,7 +480,7 @@ bool UFM01Component::process_active_stream_() { continue; if (validate_active_frame(this->data_)) { - this->on_active_frame_(this->data_); + this->on_active_frame_(this->data_, now); this->read_index_ = 0; got_valid_frame = true; continue; @@ -292,33 +503,89 @@ bool UFM01Component::process_active_stream_() { return got_valid_frame; } -void UFM01Component::set_startup_phase_(StartupPhase phase) { - this->startup_phase_ = phase; - this->phase_start_ms_ = millis(); -} - -void UFM01Component::enter_active_stream_(const char *reason) { - ESP_LOGI(TAG, "UFM-01 active stream %s", reason); - this->operating_mode_ = OperatingMode::ACTIVE_STREAM; +void UFM01Component::set_mode_(OperatingMode mode, uint32_t now) { + this->operating_mode_ = mode; + this->phase_start_ms_ = now; this->passive_read_pending_ = false; +#ifdef USE_UFM01_SOFTWARE_VERSION + this->software_version_read_pending_ = false; +#endif + this->consecutive_passive_failures_ = 0; } -void UFM01Component::start_passive_read_() { - this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); +void UFM01Component::set_startup_phase_(StartupPhase phase, uint32_t now) { + this->startup_phase_ = phase; + this->phase_start_ms_ = now; +} + +void UFM01Component::send_reset_(const LogString *status, uint32_t now) { + this->status_set_warning(status); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); +} + +void UFM01Component::enter_active_stream_(const LogString *reason, uint32_t now) { + ESP_LOGI(TAG, "UFM-01 active stream %s", LOG_STR_ARG(reason)); + this->set_mode_(OperatingMode::ACTIVE_STREAM, now); +} + +void UFM01Component::enter_passive_from_stale_(uint32_t now) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->publish_stale_flow_and_temperature_(); + // Flushes leftover stream bytes first; the ACK is not awaited, the next command's flush discards it + this->send_command_no_wait_(PASSIVE_MODE); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; + this->status_set_warning(LOG_STR("UFM-01 passive poll")); +} + +void UFM01Component::restart_startup_(const LogString *reason, uint32_t now) { + ESP_LOGW(TAG, "%s, re-initializing UFM-01", LOG_STR_ARG(reason)); + this->publish_stale_flow_and_temperature_(); + this->set_mode_(OperatingMode::STARTUP, now); + this->send_reset_(LOG_STR("re-initializing UFM-01"), now); +} + +void UFM01Component::note_passive_poll_result_(bool success, uint32_t now) { + if (success) { + this->consecutive_passive_failures_ = 0; + return; + } + this->status_set_warning(LOG_STR("UFM-01 passive poll failed")); + if (++this->consecutive_passive_failures_ < PASSIVE_FAIL_ESCALATE_COUNT) + return; + this->restart_startup_(LOG_STR("Passive poll failed repeatedly"), now); +} + +void UFM01Component::start_passive_read_(uint32_t now) { +#ifdef USE_UFM01_METER_ID + if (this->meter_id_text_sensor_ != nullptr && !this->meter_id_published_) { + this->passive_expected_size_ = PASSIVE_FRAME_WITH_ID_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2_WITH_ID; + this->send_command_no_wait_(READ_SENSOR_DATA_WITH_ID); + } else +#endif + { + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + } this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_start_ms_ = now; } -// Accumulates the reply to a passive read request across loop iterations. -PassiveReadResult UFM01Component::continue_passive_read_() { - while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { +ReadResult UFM01Component::continue_passive_read_(uint32_t now) { + const uint8_t expected_size = this->passive_expected_size_; + + while (this->available() && this->passive_index_ < expected_size) { uint8_t byte; if (!this->read_byte(&byte)) break; if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) continue; - if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + if (this->passive_index_ == 1 && byte != this->passive_start_byte_2_) { // The mismatched byte may itself be the start of the real frame this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; continue; @@ -326,57 +593,69 @@ PassiveReadResult UFM01Component::continue_passive_read_() { this->passive_frame_[this->passive_index_++] = byte; } - if (this->passive_index_ < PASSIVE_FRAME_SIZE) { - if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; - ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; - } - - if (!validate_passive_frame(this->passive_frame_)) { - log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); - ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + if (this->passive_index_ < expected_size) { + if (now - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%u/%u bytes)", this->passive_index_, expected_size); + return ReadResult::READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; - passive_no_id_to_active_frame(this->passive_frame_, active_frame); - this->on_active_frame_(active_frame); - return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +#ifdef USE_UFM01_METER_ID + if (expected_size == PASSIVE_FRAME_WITH_ID_SIZE) { + if (!validate_passive_with_id_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_WITH_ID_SIZE); + ESP_LOGW(TAG, "invalid passive frame with ID"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_WITH_ID_LAYOUT, active_frame); + // Device ID and the byte after it + std::copy_n(&this->passive_frame_[PASSIVE_WITH_ID_DEVICE_ID_INDEX], DEVICE_ID_LENGTH + 1, + &active_frame[FRAME_DEVICE_ID_INDEX]); + } else +#endif + { + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_NO_ID_LAYOUT, active_frame); + active_frame[FRAME_DEVICE_ID_INDEX + DEVICE_ID_LENGTH] = 0x01; + } + this->on_active_frame_(active_frame, now); + return ReadResult::READ_RESULT_SUCCESS; } -void UFM01Component::loop_startup_() { - const uint32_t elapsed = millis() - this->phase_start_ms_; +void UFM01Component::loop_startup_(uint32_t now) { + const uint32_t elapsed = now - this->phase_start_ms_; switch (this->startup_phase_) { case StartupPhase::WAIT: // Pick up an already-streaming device without resetting it - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < this->startup_wait_ms_) return; ESP_LOGD(TAG, "Running startup sequence"); - this->status_set_warning("initializing UFM-01"); - this->reset_retried_ = false; - this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->send_reset_(LOG_STR("initializing UFM-01"), now); return; case StartupPhase::RESET_WAIT_ACK: if (this->consume_ack_()) { - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); return; } if (elapsed < COMMAND_ACK_TIMEOUT_MS) return; if (!this->reset_retried_) { ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); - this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT, now); } else { ESP_LOGW(TAG, "Reset failed during startup"); - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); } return; @@ -385,91 +664,138 @@ void UFM01Component::loop_startup_() { return; this->reset_retried_ = true; this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); return; case StartupPhase::POST_RESET_WAIT: if (elapsed < POST_RESET_DELAY_MS) return; +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_ && + this->software_version_failures_ < SOFTWARE_VERSION_MAX_ATTEMPTS) { + this->start_software_version_read_(now); + this->set_startup_phase_(StartupPhase::SOFTWARE_VERSION_WAIT_REPLY, now); + return; + } +#endif this->send_command_no_wait_(ACTIVE_MODE); - this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); return; +#ifdef USE_UFM01_SOFTWARE_VERSION + case StartupPhase::SOFTWARE_VERSION_WAIT_REPLY: { + const ReadResult result = this->continue_software_version_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) + return; + if (result == ReadResult::READ_RESULT_SUCCESS) { + ESP_LOGD(TAG, "Software version read during startup"); + } else { + ESP_LOGW(TAG, "Software version read failed during startup, continuing"); + } + this->finish_software_version_read_(result, now); + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); + return; + } +#endif + case StartupPhase::ACTIVE_WAIT_FRAME: // The command ACK (0xE5) is consumed by the frame parser as noise - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) return; - this->start_passive_read_(); - this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + this->send_command_no_wait_(PASSIVE_MODE); + this->set_startup_phase_(StartupPhase::SET_PASSIVE_WAIT_ACK, now); + return; + + case StartupPhase::SET_PASSIVE_WAIT_ACK: + if (this->consume_ack_()) { + ESP_LOGD(TAG, "Passive mode acknowledged during startup"); + } else if (elapsed < COMMAND_ACK_TIMEOUT_MS) { + return; + } else { + ESP_LOGW(TAG, "SET_PASSIVE_MODE not acknowledged during startup, continuing"); + } + this->start_passive_read_(now); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY, now); return; case StartupPhase::PASSIVE_WAIT_REPLY: - switch (this->continue_passive_read_()) { - case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + switch (this->continue_passive_read_(now)) { + case ReadResult::READ_RESULT_PENDING: return; - case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + case ReadResult::READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = millis(); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; return; - case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + case ReadResult::READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, now); return; } } } -void UFM01Component::loop_active_stream_() { - this->process_active_stream_(); - if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { - ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = 0; - this->status_set_warning("UFM-01 passive poll"); - } +void UFM01Component::loop_active_stream_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + + this->process_active_stream_(now); + + if (this->last_valid_frame_ms_ != 0 && now - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) + this->enter_passive_from_stale_(now); } -void UFM01Component::loop_passive_poll_() { +void UFM01Component::loop_passive_poll_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + if (this->passive_read_pending_) { - const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + const ReadResult result = this->continue_passive_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) - this->status_set_warning("UFM-01 passive poll failed"); + this->note_passive_poll_result_(result == ReadResult::READ_RESULT_SUCCESS, now); return; } - if (this->process_active_stream_()) { - this->enter_active_stream_("resumed"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("resumed"), now); return; } - if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { - this->last_poll_ms_ = millis(); - this->start_passive_read_(); + if (now - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = now; + this->start_passive_read_(now); this->passive_read_pending_ = true; } } void UFM01Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + this->loop_pending_clear_action_(now); + if (this->clear_pending_ && this->pending_clear_sent_) + return; +#endif switch (this->operating_mode_) { case OperatingMode::STARTUP: - this->loop_startup_(); + this->loop_startup_(now); return; case OperatingMode::ACTIVE_STREAM: - this->loop_active_stream_(); + this->loop_active_stream_(now); return; case OperatingMode::PASSIVE_POLL: - this->loop_passive_poll_(); + this->loop_passive_poll_(now); return; } } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 0a39dcc9af..cf73daa5fa 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -8,6 +8,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -23,6 +26,17 @@ class TestableUFM01; static constexpr size_t FRAME_SIZE = 32; static constexpr size_t PASSIVE_FRAME_SIZE = 23; +static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39; +#ifdef USE_UFM01_METER_ID +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_WITH_ID_SIZE; +#else +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_SIZE; +#endif +static constexpr size_t SOFTWARE_VERSION_RESPONSE_SIZE = 7; +static constexpr size_t DEVICE_ID_LENGTH = 5; +static constexpr size_t DEVICE_ID_STRING_LENGTH = 10; +static constexpr size_t SOFTWARE_VERSION_LENGTH = 4; +static constexpr size_t SOFTWARE_VERSION_STRING_LENGTH = 8; enum class OperatingMode : uint8_t { STARTUP = 0, @@ -31,18 +45,22 @@ enum class OperatingMode : uint8_t { }; enum class StartupPhase : uint8_t { - WAIT = 0, - RESET_WAIT_ACK = 1, - RESET_RETRY_WAIT = 2, - POST_RESET_WAIT = 3, - ACTIVE_WAIT_FRAME = 4, - PASSIVE_WAIT_REPLY = 5, + WAIT, + RESET_WAIT_ACK, + RESET_RETRY_WAIT, + POST_RESET_WAIT, +#ifdef USE_UFM01_SOFTWARE_VERSION + SOFTWARE_VERSION_WAIT_REPLY, +#endif + ACTIVE_WAIT_FRAME, + SET_PASSIVE_WAIT_ACK, + PASSIVE_WAIT_REPLY, }; -enum class PassiveReadResult : uint8_t { - PASSIVE_READ_RESULT_PENDING = 0, - PASSIVE_READ_RESULT_SUCCESS = 1, - PASSIVE_READ_RESULT_FAILURE = 2, +enum class ReadResult : uint8_t { + READ_RESULT_PENDING = 0, + READ_RESULT_SUCCESS = 1, + READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { @@ -59,6 +77,13 @@ class UFM01Component : public uart::UARTDevice, public Component { SUB_BINARY_SENSOR(flow_rate_out_of_range) #endif +#ifdef USE_UFM01_METER_ID + SUB_TEXT_SENSOR(meter_id) +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + SUB_TEXT_SENSOR(software_version) +#endif + public: void setup() override; @@ -66,43 +91,85 @@ class UFM01Component : public uart::UARTDevice, public Component { void loop() override; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + /// Queue a clear of the accumulated flow; sent once the UART is idle, a request while one is pending joins it + void request_clear_accumulated_flow(); +#endif + float get_setup_priority() const override; - protected: - bool clear_accumulated_flow_(); - bool set_active_mode_(); - bool reset_device_(); - private: - bool send_command_(const std::array &command); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool can_start_clear_action_() const; + void loop_pending_clear_action_(uint32_t now); + void finish_pending_clear_(); +#endif void send_command_no_wait_(const std::array &command); bool consume_ack_(); void flush_rx_(); - bool process_active_stream_(); - void on_active_frame_(uint8_t data[FRAME_SIZE]); + bool process_active_stream_(uint32_t now); + void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now); +#ifdef USE_UFM01_METER_ID + void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]); +#endif + void publish_stale_flow_and_temperature_(); +#ifdef USE_UFM01_SOFTWARE_VERSION + void start_software_version_read_(uint32_t now); + ReadResult continue_software_version_read_(uint32_t now); + void finish_software_version_read_(ReadResult result, uint32_t now); + bool service_software_version_(uint32_t now); +#endif - void loop_startup_(); - void loop_active_stream_(); - void loop_passive_poll_(); - void set_startup_phase_(StartupPhase phase); - void enter_active_stream_(const char *reason); - void start_passive_read_(); - PassiveReadResult continue_passive_read_(); + void loop_startup_(uint32_t now); + void loop_active_stream_(uint32_t now); + void loop_passive_poll_(uint32_t now); + void set_mode_(OperatingMode mode, uint32_t now); + void set_startup_phase_(StartupPhase phase, uint32_t now); + void send_reset_(const LogString *status, uint32_t now); + void enter_active_stream_(const LogString *reason, uint32_t now); + void enter_passive_from_stale_(uint32_t now); + void restart_startup_(const LogString *reason, uint32_t now); + void start_passive_read_(uint32_t now); + ReadResult continue_passive_read_(uint32_t now); + void note_passive_poll_result_(bool success, uint32_t now); + + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + // Shared by the passive read and the software version read, which never overlap + uint32_t passive_start_ms_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + uint32_t last_software_version_attempt_ms_{0}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + uint32_t pending_clear_start_ms_{0}; +#endif + int32_t read_index_{0}; OperatingMode operating_mode_{OperatingMode::STARTUP}; StartupPhase startup_phase_{StartupPhase::WAIT}; - uint32_t phase_start_ms_{0}; - uint32_t startup_wait_ms_{0}; bool reset_retried_{false}; - uint32_t last_valid_frame_ms_{0}; - uint32_t last_poll_ms_{0}; - bool passive_read_pending_{false}; - uint32_t passive_start_ms_{0}; - size_t passive_index_{0}; - uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; + uint8_t consecutive_passive_failures_{0}; + uint8_t passive_index_{0}; + uint8_t passive_expected_size_{PASSIVE_FRAME_SIZE}; + uint8_t passive_start_byte_2_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + bool software_version_published_{false}; + bool software_version_read_pending_{false}; + uint8_t software_version_failures_{0}; +#endif +#ifdef USE_UFM01_METER_ID + bool meter_id_published_{false}; + bool meter_id_invalid_logged_{false}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool clear_pending_{false}; + bool pending_clear_sent_{false}; +#endif - int32_t read_index_ = 0; + uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE]; uint8_t data_[FRAME_SIZE]; friend class testing::TestableUFM01; diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index e49531c80b..08cd13e3d2 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -29,7 +29,7 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( # Schema for individual CDC ACM interface instances INTERFACE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), } ) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 2898576b32..0875966071 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,13 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, + mark_unclocked, +) from esphome.components.usb_host import ( get_max_packet_size, register_usb_client, @@ -39,7 +45,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: ) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, @@ -158,7 +163,9 @@ def channel_schema(type_: "Type") -> cv.Schema: cv.ensure_list( cv.Schema( { - cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.GenerateID(): cv.All( + cv.declare_id(USBUartChannel), mark_unclocked + ), cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( min=64, max=8192 ), diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b069..ed5a034b82 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1..e920da6c26 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd9..e10da78aaf 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308..36107c093c 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729..f61642566c 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef..4e52fb60e2 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; 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/web_server.cpp b/esphome/components/web_server/web_server.cpp index 062007b02e..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,46 @@ 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. +template struct ActionEntry { + char name[16]; + Call &(Call::*action)(); +}; + +// Runs the entry whose name matches the request's action; false when none does. +template +static bool run_action(const UrlMatch &match, Call &call, const ActionEntry (&table)[N]) { + for (const auto &entry : table) { + if (match.method_equals(reinterpret_cast(entry.name))) { + Call &(Call::*action)(); + progmem_memcpy(&action, &entry.action, sizeof(action)); + (call.*action)(); + return true; + } + } + return false; +} // Out of line: every GET handler ends with this [[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { @@ -327,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(); @@ -350,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); } @@ -359,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); @@ -391,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 @@ -406,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) { @@ -595,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(); @@ -674,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 @@ -713,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); } @@ -1020,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); } @@ -1051,27 +1145,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for cover methods - static const struct { - const char *name; - cover::CoverCall &(cover::CoverCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, {"stop", &cover::CoverCall::set_command_stop}, {"toggle", &cover::CoverCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1101,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)); @@ -1165,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 @@ -1176,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); } } @@ -1404,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); @@ -1467,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); } @@ -1566,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(); @@ -1581,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); @@ -1603,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)); @@ -1745,27 +1833,14 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for valve methods - static const struct { - const char *name; - valve::ValveCall &(valve::ValveCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &valve::ValveCall::set_command_open}, {"close", &valve::ValveCall::set_command_close}, {"stop", &valve::ValveCall::set_command_stop}, {"toggle", &valve::ValveCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1793,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)); @@ -1831,11 +1906,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques static_cast(&decltype(call)::set_code)); - // Lookup table for alarm control panel methods - static const struct { - const char *name; - alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, @@ -1843,16 +1914,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found) { + if (!run_action(match, call, ACTIONS)) { request->send(404); return; } @@ -1954,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(); @@ -2227,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); } } @@ -2283,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); } @@ -2628,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..2153e232a0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -335,9 +335,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 +850,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..f9094b05c5 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()); 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/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index 2888fc0abb..b7b9e5cfa9 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -58,10 +58,7 @@ void WTS01Sensor::process_packet_() { // Based on Tasmota implementation // Format: 55 01 01 04 01 11 16 12 95 // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum - uint8_t calculated_checksum = 0; - for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { - calculated_checksum += this->buffer_[i]; - } + const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1); uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; if (calculated_checksum != received_checksum) { 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/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index d01eddb9ff..44f83696f2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, @@ -14,14 +15,19 @@ from esphome.components.esp32.const import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ESPHOME, CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START, CONF_POLLING_INTERVAL, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( @@ -36,6 +42,8 @@ from .const import ( CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, REPORT, ZigbeeComponent, @@ -70,9 +78,9 @@ CONFLICTS_WITH = ["openthread"] def _check_report_deprecation(value: str) -> str: - if str(value).lower() in ("coordinator", "enable"): + if str(value).lower() == "coordinator": _LOGGER.warning( - "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + "Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead." ) return value @@ -143,12 +151,33 @@ def _validate_router_sleepy(config: ConfigType) -> ConfigType: return config +def _validate_ascii(value): + """Validate that a string contains only ascii characters.""" + if not isinstance(value, str): + raise cv.Invalid("expected string") + if not value.isascii(): + raise cv.Invalid("Only ASCII characters are allowed") + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), cv.Optional(CONF_MODEL, default=CORE.name): cv.All( - cv.string, cv.Length(max=31) + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional( + CONF_MANUFACTURER, default="esphome", visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional(CONF_VERSION, default=0): cv.All( + cv.int_range(min=0x00, max=0xFF), ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), @@ -207,7 +236,7 @@ CONFIG_SCHEMA = cv.All( def validate_number_of_ep(config: ConfigType) -> ConfigType: if not CORE.is_nrf52: return config - if KEY_ZIGBEE not in CORE.data: + if KEY_EP_NUMBER not in CORE.data.get(KEY_ZIGBEE, {}): raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if count == 1: @@ -220,7 +249,38 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: return config +def final_validate_strings(config: ConfigType) -> ConfigType: + version_str = ESPHOME_VERSION + if ( + proj_version := fv.full_config.get() + .get(CONF_ESPHOME, {}) + .get(CONF_PROJECT, {}) + .get(CONF_VERSION) + ): + version_str = proj_version.encode("ascii", "ignore")[:15].decode() + if version_str != proj_version: + _LOGGER.warning( + "esphome.project.version %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + proj_version, + version_str, + ) + area_str = "" + if CORE.area: + area_str = CORE.area.encode("ascii", "ignore")[:15].decode() + if area_str != CORE.area: + _LOGGER.warning( + "esphome.area %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + CORE.area, + area_str, + ) + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + data[KEY_ZIGBEE_VERSION_STR] = version_str + data[KEY_ZIGBEE_AREA_STR] = area_str + return config + + FINAL_VALIDATE_SCHEMA = cv.All( + final_validate_strings, validate_number_of_ep, final_validate_esp32, ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index aad2b5c0e3..109b953de5 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -85,6 +85,8 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" +KEY_ZIGBEE_AREA_STR = "zigbee_area_str" +KEY_ZIGBEE_VERSION_STR = "zigbee_version_str" # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e9..0572e591cf 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 7f0a5f979c..0de0ee0ee4 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include namespace esphome::zigbee { @@ -9,12 +10,14 @@ ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -25,7 +28,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -38,8 +40,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -61,8 +67,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index b73c26a836..e2ff542976 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -62,6 +62,9 @@ void ZigbeeAttribute::report_(bool has_lock) { } void ZigbeeAttribute::set_report(ZigbeeReportT report) { + if (report == ZigbeeReportT::ZIGBEE_REPORT_DEFAULT || report == ZigbeeReportT::ZIGBEE_REPORT_COORDINATOR) { + return; + } this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; @@ -73,6 +76,14 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { }); } +void ZigbeeAttribute::report() { + if (!this->report_enabled) { + return; + } + this->report_requested_ = true; + this->enable_loop(); +} + void ZigbeeAttribute::loop() { if (this->set_attr_requested_) { this->set_attr_(); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 305febf171..adaa60646d 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -53,6 +53,7 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + void report(); template void add_on_value_callback(F &&callback) { on_value_callback_.add(std::forward(callback)); } void on_value(ezb_zcl_attribute_t attribute) { diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 88f49ef3c1..1616ce0ceb 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,13 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda -from .const import ( - CONF_MAX_EP_NUMBER, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, - KEY_ZIGBEE, - REPORT, -) +from .const import CONF_MAX_EP_NUMBER, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE from .const_esp32 import ( ALLOWED_UNITS, CONF_ATTRIBUTE_ID, @@ -58,7 +52,6 @@ ANALOG_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -87,7 +80,6 @@ ANALOG_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -117,7 +109,6 @@ BINARY_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -146,7 +137,6 @@ BINARY_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -178,7 +168,6 @@ def _pressure_ep(device_type: bool = False) -> dict[str, Any]: { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_HECTOPASCAL: 1, @@ -208,7 +197,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -226,7 +214,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -247,7 +234,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_LITRE_PER_HOUR: 0.01, @@ -269,7 +255,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_LAMBDA: cv.lambda_( Lambda( "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN @@ -294,7 +279,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -316,7 +300,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0000, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: 0.000001, }, @@ -339,7 +322,6 @@ BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -369,7 +351,6 @@ SWITCH_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, ], diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 7c197133c2..68c0d005b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,6 +49,12 @@ void ZigbeeComponent::factory_reset() { esp_zigbee_lock_release(); } +void ZigbeeComponent::report() { + for (const auto &[_, attribute] : this->attributes_) { + attribute->report(); + } +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, @@ -238,7 +244,8 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, const char *location, + const char *sw_build_id, uint8_t power_source, uint8_t app_version) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -247,7 +254,10 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .location = get_zcl_string(location, 15), + .sw_build_id = get_zcl_string(sw_build_id, 15), .power_source = power_source, + .app_version = app_version, }; } @@ -267,6 +277,16 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_APPLICATION_VERSION_ID, + &(this->basic_cluster_data_.app_version)); + uint8_t stack_version = 0; + uint8_t hw_version = 0; + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_STACK_VERSION_ID, &stack_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_HW_VERSION_ID, &hw_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_LOCATION_DESCRIPTION_ID, + this->basic_cluster_data_.location); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_SW_BUILD_ID_ID, + this->basic_cluster_data_.sw_build_id); } bool ZigbeeComponent::register_device() { @@ -420,37 +440,35 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %.*s\n" + " Manufacturer: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " App Version: %u", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), + this->basic_cluster_data_.manufacturer[0], + reinterpret_cast(this->basic_cluster_data_.manufacturer + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + this->basic_cluster_data_.app_version); if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG( - TAG, - "Zigbee\n" - " Model: %.*s\n" -#ifdef CONFIG_ZB_ZCZR - " Router: %s\n" -#else - " Sleepy end device: %s\n" - " Poll interval: %" PRIu32 " ms\n" -#endif - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), -#ifdef CONFIG_ZB_ZCZR - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), -#else - YESNO(this->sleepy_), this->keep_alive_, -#endif - YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); - esp_zigbee_lock_release(); - } else { ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), + ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 28ea64c8ce..d5ed7439bd 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -44,7 +44,8 @@ class ZigbeeComponent final : public Component { void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } - void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); + void set_basic_cluster(const char *model, const char *manufacturer, const char *location, const char *sw_build_id, + uint8_t power_source, uint8_t app_version); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); bool register_device(); @@ -60,6 +61,7 @@ class ZigbeeComponent final : public Component { static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); void factory_reset(); + void report(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } @@ -80,7 +82,10 @@ class ZigbeeComponent final : public Component { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t *location; + uint8_t *sw_build_id; uint8_t power_source; + uint8_t app_version; } basic_cluster_data_; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 68136d5719..99f075ecb2 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -3,9 +3,12 @@ import logging import re from typing import Any +from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, + DOMAIN as ESP32_DOMAIN, add_idf_component, add_idf_sdkconfig_option, add_partition, @@ -28,6 +31,7 @@ from esphome.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, + CONF_VERSION, CONF_WIFI, DEVICE_CLASS_OUTLET, ) @@ -49,8 +53,11 @@ from .const import ( CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, + ZigbeeComponent, ) from .const_esp32 import ( ALLOWED_UNITS, @@ -200,8 +207,11 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) if attr.get(CONNECT): # connect device - if CONF_REPORT in config: - attr[CONF_REPORT] = config[CONF_REPORT] + if (report := config.get(CONF_REPORT, "default")) not in ( + "default", + "coordinator", + ): + attr[CONF_REPORT] = report attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) attr_list.append(attr) @@ -418,12 +428,14 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": cg.add(var.set_sleepy(True)) if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: cg.add(var.set_keep_alive(keep_alive)) - cg.add( var.set_basic_cluster( config[CONF_MODEL], - "esphome", + config[CONF_MANUFACTURER], + zb_data[KEY_ZIGBEE_AREA_STR], + zb_data[KEY_ZIGBEE_VERSION_STR], POWER_SOURCE[config[CONF_POWER_SOURCE]], + config[CONF_VERSION], ) ) for ep_num, ep in ep_dict.items(): @@ -454,3 +466,21 @@ async def add_component(entity: cg.MockObj, config: ConfigType) -> None: cg.add(zb_attr.connect(template_arg, entity, lambda_)) else: cg.add(zb_attr.connect(template_arg, entity)) + + +ZIGBEE_ACTION_SCHEMA_ESP32 = automation.maybe_simple_id( + cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + }, + ), + cv.requires_component(ESP32_DOMAIN), + ) +) + +automation.register_apply_action( + "zigbee.report", + ZIGBEE_ACTION_SCHEMA_ESP32, + automation.ApplyCall("report()"), +) diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 8197796b6d..d468c9881d 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -2,6 +2,7 @@ import datetime import random import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -9,7 +10,7 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - __version__, + CONF_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import ( @@ -27,6 +28,8 @@ from .const import ( CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, AnalogAttrs, AnalogAttrsOutput, @@ -158,15 +161,16 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list + zb_data = CORE.data.get(KEY_ZIGBEE, {}) basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) zigbee_new_attr_list( "zigbee_basic_attrib_list", "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), - zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.app_version, config[CONF_VERSION]), zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), - zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.mf_name, config[CONF_MANUFACTURER]), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( basic_attrs.date_code, @@ -178,11 +182,11 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.power_source, POWER_SOURCE[config[CONF_POWER_SOURCE]], ), - zigbee_set_string(basic_attrs.location_id, ""), + zigbee_set_string(basic_attrs.location_id, zb_data[KEY_ZIGBEE_AREA_STR]), zigbee_assign( basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") ), - zigbee_set_string(basic_attrs.sw_ver, __version__), + zigbee_set_string(basic_attrs.sw_ver, zb_data[KEY_ZIGBEE_VERSION_STR]), ) # Create the identify attributes structure and attribute list 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 d3f1fe59e5..35c2a17c24 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include @@ -494,6 +495,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8762cbe711..09cde1261d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -21,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -39,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -66,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -310,10 +320,9 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // 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. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -324,6 +333,13 @@ template class IfAction : public Action { this->else_.add_action(&this->else_continuation_.action); } + // 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 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 play_complex(const Ts &...x) override { this->num_running_++; if (this->condition_->check(x...)) { @@ -363,11 +379,14 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -427,11 +446,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; @@ -500,7 +522,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +554,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +574,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +592,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +609,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de621f891a..943458e3f6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -63,6 +63,7 @@ #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY +#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT @@ -110,6 +111,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 @@ -166,6 +169,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 @@ -174,16 +179,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 @@ -243,14 +254,20 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR +#define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX +#define USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +#define USE_UFM01_METER_ID +#define USE_UFM01_SOFTWARE_VERSION #define USE_UPDATE #define USE_UPTIME_TIMESTAMP #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 @@ -277,6 +294,7 @@ #define USE_API_WIZARD #define USE_API_WIZARD_INPUTS #define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_MULTIPLE_INPUTS #define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS @@ -445,6 +463,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 @@ -455,6 +474,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 @@ -518,6 +539,7 @@ #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD +#define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD @@ -579,6 +601,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -638,6 +661,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 @@ -710,3 +734,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 a2ede27341..bcb03b010f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -879,6 +880,13 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } +/// Add up the bytes of \p data with size \p len, truncated to 8 bits: the checksum many serial and IR +/// protocols use. Prefer this over a bare std::accumulate, whose result type follows the seed, so a plain 0 +/// sums in int and compares wrong against a checksum byte. +constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); @@ -1248,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 @@ -2206,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) @@ -2221,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) @@ -2293,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 4c0ce28a5a..1427541229 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -272,6 +272,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 @@ -283,6 +284,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/cpp_generator.py b/esphome/cpp_generator.py index 9c9fed6601..1196669662 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -540,6 +540,19 @@ def shared_progmem_array( return array +def shared_progmem_string(value: str) -> Expression: + """Return a string that stays in flash: one shared PROGMEM array per text on ESP8266. + + Other platforms keep string literals in flash already, so they get the plain literal. + """ + from esphome.cpp_types import char + + text = StringLiteral(value) + if CORE.is_esp8266: + return shared_progmem_array("progmem_str", char, text) + return text + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ac76cd41f..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: @@ -107,7 +107,7 @@ dependencies: rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: - version: 9.5.0 + version: 9.6.0 fastled/FastLED: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b 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/preferences.py b/esphome/preferences.py index fce8519130..6e7e4eda33 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -19,18 +19,15 @@ STORAGE_FLASH = "flash" STORAGE_RTC = "rtc" -def _rtc_supported() -> bool: - """Whether the active platform has an RTC-backed preferences backend. - - Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 - and -C61 have no RTC memory at all, so RTC storage is unavailable there. - """ +def rtc_supported() -> bool: + """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) return False @@ -44,9 +41,10 @@ def _default_storage() -> str: return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH -def _validate_storage(value): +def validate_storage(value: object) -> str: + """Validate a ``storage:`` value, ``flash`` or ``rtc`` where supported.""" value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) - if value == STORAGE_RTC and not _rtc_supported(): + if value == STORAGE_RTC and not rtc_supported(): raise cv.Invalid( f"'{STORAGE_RTC}' storage is not supported on this platform; only " f"'{STORAGE_FLASH}' is available" @@ -56,7 +54,7 @@ def _validate_storage(value): def storage_schema(): """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" - return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + return {cv.Optional(CONF_STORAGE, default=_default_storage): validate_storage} def request_rtc_storage() -> None: @@ -88,7 +86,7 @@ def validate_rtc_storage(value): "be disabled" ) return value - if not _rtc_supported(): + if not rtc_supported(): raise cv.Invalid("RTC preference storage is not supported on this platform") return value 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/yaml_util.py b/esphome/yaml_util.py index e892664724..5882c133f6 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -11,6 +11,7 @@ import logging import math import os from pathlib import Path +import re from typing import Any import uuid @@ -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. @@ -440,6 +463,16 @@ def _load_include_candidates( ) +# `!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)} + + def force_load_include_files( obj: Any, *, @@ -842,6 +875,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 +1048,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 +1154,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 +1203,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 +1401,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 +1432,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 +1444,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 +1488,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 +1523,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 +1548,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 7534b748d1..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 @@ -108,7 +108,7 @@ build_unflags = lib_deps = esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] @@ -646,7 +646,7 @@ extends = common platform = platformio/native lib_deps = esphome/noise-c@0.1.31 ; used by noise (api, ota) - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl build_flags = ${common.build_flags} -DUSE_HOST 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/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/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index adbfdf306e..69c9605a67 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re import pytest @@ -28,35 +29,46 @@ CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yam def test_metadata_is_emitted_as_progmem_table( generate_main: Callable[[str | Path], str], ) -> None: - """Every action string is a PROGMEM array referenced from one PROGMEM table.""" + """Every action string is a literal referenced from one PROGMEM table.""" main_cpp = generate_main(CONFIG) - assert ( - 'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp - ) - assert ( - 'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";' - in main_cpp - ) - assert ( - 'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";' - in main_cpp - ) assert ( "static constexpr const char * api_action0_strings[] PROGMEM = {" - "api_action_str0, api_action_str1, api_action_str2, api_action_str3, " - "api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp + '"play_buzzer", "Play an RTTTL melody on the buzzer", "song_str", ' + '"RTTTL melody string", "two_short:d=4,o=5,b=100:16e6,16e6", "volume", ' + "nullptr, nullptr};" in main_cpp ) # An action without metadata still carries the metadata slots (as nullptr) assert ( "static constexpr const char * api_action1_strings[] PROGMEM = {" - "api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp + '"plain_action", nullptr, "value", nullptr, nullptr};' in main_cpp ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None +def test_esp8266_strings_are_shared_progmem_arrays( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP8266 each string is its own PROGMEM array, referenced from the table.""" + main_cpp = generate_main(CONFIG_ESP8266) + + def array_for(text: str) -> str: + match = re.search( + rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp + ) + assert match, text + return match.group(1) + + assert array_for("play_buzzer") + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + f"{array_for('plain_action')}, nullptr, {array_for('value')}, nullptr, nullptr}};" + in main_cpp + ) + + def test_esp8266_sizes_scratch_buffer_for_largest_action( generate_main: Callable[[str | Path], str], ) -> None: @@ -76,7 +88,7 @@ def test_shorthand_variables_emit_no_metadata( assert ( "static constexpr const char * api_action0_strings[] PROGMEM = " - "{api_action_str0, api_action_str1};" in main_cpp + '{"plain_action", "value"};' in main_cpp ) assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines} diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 112092482b..f55a6974e7 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -490,6 +490,7 @@ WIZARD_DEFINES = { "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", } @@ -550,9 +551,28 @@ WIZARD_DEFINES = { ), pytest.param( "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", - WIZARD_DEFINES, + WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"}, id="everything", ), + pytest.param( + "- inputs: [{id: calendars, multiple: true}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", + }, + id="multiple-input", + ), + pytest.param( + "- inputs: [{id: calendars, multiple: false}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="multiple-false", + ), ], ) def test_only_the_defines_the_wizard_needs_are_emitted( @@ -925,3 +945,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( assert "api_wizard_input_ha_plain_sensor" not in main_cpp # Every buffer starts empty assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] + + +@pytest.mark.parametrize( + "input_conf", + [ + "{id: cals, multiple: true}", + "{id: cals, multiple: true, max_length: 255}", + "{id: cals, multiple: true, max_length: 4095}", + "{id: cals, multiple: false}", + ], +) +def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None: + api = wizard_inputs(f"- {input_conf}") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- {entity: ha_sensor, multiple: true}", + "multiple is only for standalone inputs", + id="multiple-linked", + ), + pytest.param( + "- {id: cals, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-without-multiple", + ), + pytest.param( + "- {id: cals, multiple: false, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-multiple-false", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 254}", + "value must be at least 255", + id="max-length-too-small", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 4096}", + "value must be at most 4095", + id="max-length-too-big", + ), + ], +) +def test_invalid_multiple_inputs_are_rejected( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_multiple_input_document_and_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_inputs( + """ + - {id: cals, multiple: true, max_length: 1023} + - {id: cals_default, multiple: true} + - {id: single} + """ + ) + main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) + inputs = document["pages"][0]["inputs"] + # multiple and max_length are only there when set + assert inputs == [ + {"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023}, + {"key": fnv1_hash("cals_default"), "multiple": True}, + {"key": fnv1_hash("single")}, + ] + # Only a multiple entry names its buffer size + assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp + assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp + assert 'static char api_wizard_input_single[256] = "";' in main_cpp + assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp + assert ( + f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}" + in main_cpp + ) + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + + +def test_inputs_without_multiple_keep_the_two_field_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single")) + ) + + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines} diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py index effe7b5b0b..5d9f9b828e 100644 --- a/tests/component_tests/automation/test_condition_groups.py +++ b/tests/component_tests/automation/test_condition_groups.py @@ -15,7 +15,7 @@ def test_single_condition_groups_are_unwrapped( assert "IfAction(lambdacondition_id);" in main_cpp assert "IfAction(lambdacondition_id_2);" in main_cpp group = re.search( - r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + r"new\((\w+)\) AndCondition<2>\(lambdacondition_id_3, lambdacondition_id_4\);", main_cpp, ) assert group is not None 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_ble_server/config/set_value.yaml b/tests/component_tests/esp32_ble_server/config/set_value.yaml new file mode 100644 index 0000000000..69f1a17dc8 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/set_value.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - id: test_characteristic + uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + descriptors: + - id: test_descriptor + uuid: 0x4414 + value: "Initial" + - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc99a + write: true + on_write: + - ble_server.characteristic.set_value: + id: test_characteristic + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.descriptor.set_value: + id: test_descriptor + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.characteristic.set_value: + id: test_characteristic + value: !lambda return x; diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 21a12d9cf2..e3006d2c69 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -9,10 +9,16 @@ import pytest from esphome.components.esp32_ble_server import ( CCCD_DESCRIPTOR_UUID, + CONF_ENDIANNESS, + CONF_STRING_ENCODING, CUD_DESCRIPTOR_UUID, DEVICE_INFORMATION_SERVICE_UUID, + float32, uuid_is, + value_bytes, ) +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_TYPE @pytest.mark.parametrize( @@ -73,3 +79,69 @@ def test_advertising_required( main_cpp = generate_main(component_config_path(config_file)) assert f"set_advertising_required({str(required).lower()})" in main_cpp + + +# The same values and bytes as tests/components/bytebuffer/test_wrap_values.cpp, which pins +# what ByteBuffer::wrap produced from the expressions this component used to emit. +@pytest.mark.parametrize( + ("data", "type_", "endianness", "expected"), + [ + (18, "uint8_t", "LITTLE", [0x12]), + (18, "uint16_t", "LITTLE", [0x12, 0x00]), + (4660, "uint16_t", "BIG", [0x12, 0x34]), + (305419896, "uint32_t", "LITTLE", [0x78, 0x56, 0x34, 0x12]), + ( + 1311768467294899695, + "uint64_t", + "BIG", + [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF], + ), + (-5, "int8_t", "LITTLE", [0xFB]), + (-2, "int16_t", "BIG", [0xFF, 0xFE]), + (-2, "int32_t", "LITTLE", [0xFE, 0xFF, 0xFF, 0xFF]), + (-2, "int64_t", "LITTLE", [0xFE] + [0xFF] * 7), + (123.1, "float", "BIG", [0x42, 0xF6, 0x33, 0x33]), + (0.1, "float", "LITTLE", [0xCD, 0xCC, 0xCC, 0x3D]), + (0.1, "double", "LITTLE", [0x9A, 0x99, 0x99, 0x99, 0x99, 0x99, 0xB9, 0x3F]), + (2.5, "double", "BIG", [0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ], +) +def test_value_bytes_match_bytebuffer( + data: float, type_: str, endianness: str, expected: list[int] +) -> None: + config = { + CONF_DATA: data, + CONF_TYPE: type_, + CONF_ENDIANNESS: endianness, + CONF_STRING_ENCODING: "utf_8", + } + assert value_bytes(config) == expected + + +def test_value_bytes_strings_and_lists() -> None: + assert value_bytes({CONF_DATA: "hé", CONF_STRING_ENCODING: "utf_8"}) == [ + 0x68, + 0xC3, + 0xA9, + ] + assert value_bytes({CONF_DATA: [1, 2], CONF_STRING_ENCODING: "utf_8"}) == [1, 2] + + +def test_set_value_constants_share_a_flash_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("set_value.yaml")) + + assert main_cpp.count("ble_server_value[] PROGMEM = {0x12, 0x34};") == 1 + assert main_cpp.count("set_buffer_static(ble_server_value, 2);") == 2 + assert "set_buffer_template(" in main_cpp + assert "ByteBuffer::wrap" not in main_cpp + + +def test_float32_rejects_out_of_range() -> None: + for value in (1e40, -1e40): + with pytest.raises(cv.Invalid, match="out of range for a float"): + float32(value) + assert float32(3.4028235e38) == 3.4028235e38 + assert float32(float("inf")) == float("inf") 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/event/__init__.py b/tests/component_tests/event/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/event/config/event_types.yaml b/tests/component_tests/event/config/event_types.yaml new file mode 100644 index 0000000000..8e849d4a78 --- /dev/null +++ b/tests/component_tests/event/config/event_types.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +event: + - platform: template + id: event_a + name: Event A + event_types: [pressed, held] + - platform: template + id: event_b + name: Event B + event_types: [pressed, held] + - platform: template + id: event_c + name: Event C + event_types: [ring] diff --git a/tests/component_tests/event/test_event_types.py b/tests/component_tests/event/test_event_types.py new file mode 100644 index 0000000000..30e653bc70 --- /dev/null +++ b/tests/component_tests/event/test_event_types.py @@ -0,0 +1,25 @@ +"""Event types are shared PROGMEM tables of type pointers.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_event_types_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("event_types.yaml")) + + calls = { + var: table + for var, table, _ in re.findall( + r"(\w+)->set_event_types_static\((\w+), (\d+)\);", main_cpp + ) + } + assert set(calls) == {"event_a", "event_b", "event_c"} + assert calls["event_a"] == calls["event_b"] != calls["event_c"] + assert ( + f'static constexpr const char * {calls["event_a"]}[] PROGMEM = {{"pressed", "held"}};' + 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/haier/__init__.py b/tests/component_tests/haier/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/haier/test_status_message_automation.py b/tests/component_tests/haier/test_status_message_automation.py new file mode 100644 index 0000000000..5f4927fe10 --- /dev/null +++ b/tests/component_tests/haier/test_status_message_automation.py @@ -0,0 +1,61 @@ +"""Tests that haier on_status_message automations reject deferring actions. + +The trigger passes a pointer into the protocol's receive buffer, which is reused for the next +frame, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.haier.climate import ( + CONF_ON_STATUS_MESSAGE, + HonClimate, + Smartair2Climate, + _base_config_schema, +) +from esphome.core import Lambda +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CLIMATE_CLASSES = [HonClimate, Smartair2Climate] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema( + class_: MockObjClass, +) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator the protocol's schema applies to on_status_message.""" + return _base_config_schema(class_).schema[cv.Optional(CONF_ON_STATUS_MESSAGE)] + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_synchronous_automation_accepted(class_: MockObjClass) -> None: + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_nested_deferring_action_rejected(class_: MockObjClass) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": actions}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_in_second_automation_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) diff --git a/tests/component_tests/http_request/config/request_headers.yaml b/tests/component_tests/http_request/config/request_headers.yaml new file mode 100644 index 0000000000..bdd4cd6630 --- /dev/null +++ b/tests/component_tests/http_request/config/request_headers.yaml @@ -0,0 +1,72 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +http_request: + +button: + - platform: template + id: shared_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: shared_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: other + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: text/plain + - platform: template + id: lambda_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + - platform: template + id: lambda_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + +script: + - id: param_a + parameters: + level: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - id: param_b + parameters: + value: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a diff --git a/tests/component_tests/http_request/test_request_headers.py b/tests/component_tests/http_request/test_request_headers.py new file mode 100644 index 0000000000..4eefa55b20 --- /dev/null +++ b/tests/component_tests/http_request/test_request_headers.py @@ -0,0 +1,37 @@ +"""Request headers are PROGMEM tables, shared only between all-constant lists.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_request_headers_share_constant_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("request_headers.yaml")) + + # Actions in config order: the five buttons, then the two scripts + tables = [ + (table, int(count)) + for table, count in re.findall( + r"->set_request_headers\((\w+), (\d+)\);", main_cpp + ) + ] + assert len(tables) == 7 + shared_a, shared_b, other, lambda_a, lambda_b, param_a, param_b = tables + assert shared_a == shared_b + assert shared_a[1] == 2 + assert other[1] == 1 + # A list with a lambda may keep static state, so it never shares even with an identical one + assert lambda_a[0] != lambda_b[0] + assert len({shared_a[0], other[0], lambda_a[0], lambda_b[0]}) == 4 + for table, _ in tables: + assert re.search(rf"\b{table}\[\] PROGMEM = \{{", main_cpp) + # Constant headers ignore their arguments, so scripts whose parameters differ + # only by name share a table, and it is the only one of its argument type + assert param_a == param_b + assert param_a[0] not in {shared_a[0], other[0], lambda_a[0], lambda_b[0]} + assert re.findall(r"RequestHeader<[^>]+> (\w+)\[\] PROGMEM = \{", main_cpp) == [ + param_a[0] + ] 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/lv_conf_check_second.yaml b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml new file mode 100644 index 0000000000..a5ccd0e68d --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml @@ -0,0 +1,41 @@ +esphome: + name: test-lv-conf-check-second + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + id: display_a + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + - platform: mipi_spi + id: display_b + data_rate: 20MHz + model: st7735 + cs_pin: GPIO4 + dc_pin: GPIO5 + +lvgl: + - id: lvgl_a + displays: display_a + widgets: + - label: + text: Hello + - id: lvgl_b + displays: display_b + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml new file mode 100644 index 0000000000..769032f839 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-lv-conf-check-warn + +esp32: + board: lolin_c3_mini + +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: + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_defaults.yaml b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml new file mode 100644 index 0000000000..94950a3b43 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-lv-conf-defaults + +esp32: + board: lolin_c3_mini + +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: + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_options.yaml b/tests/component_tests/lvgl/config/lv_conf_options.yaml new file mode 100644 index 0000000000..969091c086 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_options.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-lv-conf-options + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + byte_order: little_endian + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + log_level: DEBUG + widgets: + - label: + text: Hello 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_list.py b/tests/component_tests/lvgl/test_list.py index 368a3a4990..e93d858ad5 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -287,10 +287,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp - assert ( - "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" - in main_cpp - ) + assert "lv_obj_set_checkable(dyn_button_VAR_, true);" in main_cpp assert ( 'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp ) diff --git a/tests/component_tests/lvgl/test_lv_conf.py b/tests/component_tests/lvgl/test_lv_conf.py new file mode 100644 index 0000000000..8b93c01166 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf.py @@ -0,0 +1,80 @@ +"""Tests for the LVGL configuration defines written to lv_conf.h.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df +from esphome.components.lvgl.schemas import WIDGET_TYPES + +# LVGL 9.6 stops the build with #error when an enabled option "selects" another +# option that is disabled. ESPHome sets every unused LV_USE_* to 0, so each widget +# must list these in get_uses(). +LVGL_SELECTS = ( + ("SLIDER", "BAR"), + ("BARCODE", "CANVAS"), + ("QRCODE", "CANVAS"), + ("CANVAS", "IMAGE"), + ("DROPDOWN", "LABEL"), + ("MSGBOX", "LABEL"), + ("ROLLER", "LABEL"), + ("TEXTAREA", "LABEL"), +) + + +def test_default_lv_conf_defines( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("lv_conf_defaults.yaml")) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == "LV_COLOR_FORMAT_RGB565_SWAPPED" + assert defines["LV_DRAW_SW_SUPPORT_RGB565_SWAPPED"] == "1" + assert defines["LV_OBJ_STYLE_CACHE"] == "1" + assert defines["LV_USE_CHECK_ARG"] == "0" + assert "LV_CHECK_ARG_LOG_MODE" not in defines + # Both are deprecated in LVGL 9.6 and produce build warnings + assert "LV_COLOR_DEPTH" not in defines + assert "LV_COLOR_16_SWAP" not in defines + + +@pytest.mark.parametrize( + ("yaml_file", "color_format", "log_mode"), + [ + ("lv_conf_options.yaml", "RGB565", "VERBOSE"), + ("lv_conf_check_warn.yaml", "RGB565_SWAPPED", "MINIMAL"), + # Only the second LVGL instance sets check_args + ("lv_conf_check_second.yaml", "RGB565_SWAPPED", "MINIMAL"), + ], +) +def test_lv_conf_options( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, + color_format: str, + log_mode: str, +) -> None: + generate_main(component_config_path(yaml_file)) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == f"LV_COLOR_FORMAT_{color_format}" + assert defines["LV_USE_CHECK_ARG"] == "1" + assert defines["LV_CHECK_ARG_LOG_MODE"] == f"LV_CHECK_ARG_LOG_MODE_{log_mode}" + + +def test_check_arg_log_modes_cover_all_log_levels() -> None: + assert df.LV_CHECK_ARG_LOG_MODES.keys() == df.LV_LOG_LEVELS.keys() + + +@pytest.mark.parametrize("widget_name", sorted(WIDGET_TYPES)) +def test_widget_uses_satisfy_lvgl_selects(widget_name: str) -> None: + widget_type = WIDGET_TYPES[widget_name] + df.add_lv_use(widget_type.name, *widget_type.get_uses()) + enabled = {use.upper() for use in df.get_lv_uses()} + for option, required in LVGL_SELECTS: + if option in enabled: + assert required in enabled, ( + f"'{widget_name}' enables LV_USE_{option}, which needs LV_USE_{required}" + ) diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index c44b40317e..9ec73ca618 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -10,11 +10,15 @@ from esphome.config_validation import Invalid def _config( - displays: list[str], theme: dict | None = None, debug_outline: bool = False + displays: list[str], + theme: dict | None = None, + debug_outline: bool = False, + check_args: bool = False, ) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", + df.CONF_CHECK_ARGS: check_args, "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, @@ -73,3 +77,8 @@ class TestDebugOutlineOnMultipleInstances: _config(["disp_b"], debug_outline=True), ] multi_conf_validate(configs) + + +def test_check_args_may_differ_between_instances() -> None: + configs = [_config(["disp_a"], check_args=True), _config(["disp_b"])] + multi_conf_validate(configs) 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/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index f8614b17a3..118f844c84 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -8,8 +8,11 @@ from esphome.components.mipi import ( BRIGHTNESS, CONF_INVERT_COLORS, CONF_PIXEL_MODE, + SLPOUT, + SWRESET, DriverChip, ) +from esphome.components.mipi_spi.display import MODELS from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run @@ -96,3 +99,23 @@ def test_get_sequence_omits_brightness_when_not_set() -> None: sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) assert not any(_has_brightness_command(sequence, v) for v in range(256)) + + +def _count_subsequence(sequence: tuple[int, ...], part: tuple[int, ...]) -> int: + return sum( + sequence[i : i + len(part)] == part + for i in range(len(sequence) - len(part) + 1) + ) + + +def test_es3c35p_waits_after_reset_and_issues_its_own_slpout() -> None: + """The ES3C35P settles 120ms after reset and sends SLPOUT itself with a 120ms wait.""" + sequence = MODELS["ES3C35P"].get_sequence( + _BASE_CONFIG, add_madctl=False, add_reset=True + ) + + assert sequence[:4] == (SWRESET, 0, 120, 255) + # Exactly one SLPOUT, followed by the panel's 120ms settle, and none appended by the framework + assert _count_subsequence(sequence, (SLPOUT, 0, 120, 255)) == 1 + assert _count_subsequence(sequence, (SLPOUT, 0, 10, 255)) == 0 + assert "ES3C35P-NS" in MODELS diff --git a/tests/component_tests/mqtt/__init__.py b/tests/component_tests/mqtt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mqtt/test_json_message_automation.py b/tests/component_tests/mqtt/test_json_message_automation.py new file mode 100644 index 0000000000..a6bb84c7c7 --- /dev/null +++ b/tests/component_tests/mqtt/test_json_message_automation.py @@ -0,0 +1,52 @@ +"""Tests that mqtt on_json_message automations reject deferring actions. + +The trigger passes a view into the parsed JSON document, which is freed once the automation +returns, so the deferring-actions guard is a safety property. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONFIG_SCHEMA, JSON_MESSAGE_AUTOMATION_SCHEMA +from esphome.const import CONF_ON_JSON_MESSAGE +from esphome.core import Lambda +from esphome.types import ConfigType + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _automation(*actions: ConfigType) -> ConfigType: + return {"topic": "test/topic", "then": list(actions)} + + +def test_schema_wired_to_on_json_message() -> None: + schema = CONFIG_SCHEMA.validators[0].schema + assert schema[cv.Optional(CONF_ON_JSON_MESSAGE)] is JSON_MESSAGE_AUTOMATION_SCHEMA + + +def test_synchronous_automation_accepted() -> None: + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(SYNCHRONOUS_ACTION)]) + + +def test_deferring_action_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION, DEFERRING_ACTION)] + ) + + +def test_nested_deferring_action_rejected() -> None: + action = {"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}} + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(action)]) + + +def test_deferring_action_in_second_automation_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION), _automation(DEFERRING_ACTION)] + ) 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/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/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/abbwelcome_payload_tables.yaml b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml new file mode 100644 index 0000000000..66e0fe4e7f --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml @@ -0,0 +1,36 @@ +--- +esphome: + name: test + on_boot: + then: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x01 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x02 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x03 + data: [] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x04 + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x05 + data: !lambda return {0x09}; + +esp32: + board: esp32dev + +remote_transmitter: + pin: 4 + carrier_duty_percent: 100% 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/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml new file mode 100644 index 0000000000..f14bc75f30 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 100% + +button: + - platform: template + name: Plain word + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + code: 0xd19ef0a9 + - platform: template + name: Constant suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: 0x2A + suffix_bits: 6 + - platform: template + name: Lambda suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: !lambda return 0x2A; + suffix_bits: !lambda return 6; 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 new file mode 100644 index 0000000000..ea1dbcf294 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -0,0 +1,25 @@ +"""Tests for abbwelcome constant data in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +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 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"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 new file mode 100644 index 0000000000..cbc1efb0a6 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -0,0 +1,23 @@ +"""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_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")) + 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_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py index 8154471e47..ceecd2f122 100644 --- a/tests/component_tests/sensor/test_timeout_throttle.py +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -15,10 +15,10 @@ def test_timeout_throttle_pair_is_merged( len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) == 1 ) - merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) - assert merged is not None - assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + merged = re.findall(r"merged->add_filter\((\w+)\);", main_cpp) + assert len(merged) == 1 + assert f"new({merged[0]}) sensor::TimeoutThrottleFilter(1000);" in main_cpp # Every other shape keeps its two filters for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): - call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) - assert call is not None and len(call.group(1).split(",")) == 2, sensor_id + calls = re.findall(rf"{sensor_id}->add_filter\(\w+\);", main_cpp) + assert len(calls) == 2, sensor_id diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml new file mode 100644 index 0000000000..89f1a80ce2 --- /dev/null +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -0,0 +1,62 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + initial_value: "0" + +sensor: + - platform: template + id: temp + lambda: return 1.0; + filters: + - multiply: 2.0 + - offset: 1.0 + - or: + - throttle: 2s + - delta: 1.0 + +binary_sensor: + - platform: template + id: button + lambda: return true; + filters: + - invert: + - delayed_on: 10ms + +text_sensor: + - platform: template + id: label + lambda: return {"x"}; + filters: + - to_upper: + - append: "!" + +interval: + - interval: 10s + then: + - if: + condition: + and: + - lambda: return id(counter) == 0; + - binary_sensor.is_on: button + then: + - lambda: id(counter) += 1; + - logger.log: even + else: + - logger.log: odd + - repeat: + count: 2 + then: + - logger.log: again + - while: + condition: + lambda: return id(counter) < 3; + then: + - lambda: id(counter) += 1; diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py new file mode 100644 index 0000000000..adf1498e37 --- /dev/null +++ b/tests/component_tests/template/test_per_item_lists.py @@ -0,0 +1,30 @@ +"""Filters and action lists are added one item at a time, so no pointer list lands in .rodata.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_filters_and_actions_are_added_per_item( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("per_item_lists.yaml")) + + for list_call in ( + "add_filters(", + "set_filters(", + "add_actions(", + "add_then(", + "add_else(", + ): + assert list_call not in main_cpp + assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 + assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 + assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 + assert main_cpp.count("->add_then_action(") == 4 + assert main_cpp.count("->add_else_action(") == 1 + assert main_cpp.count("->finish_then();") == 3 + assert main_cpp.count("->finish_else();") == 1 + # and/or conditions and the sensor `or` filter take their items as separate arguments + assert not re.search(r"(Condition|OrFilter)<[^>]*>\(\{", main_cpp) diff --git a/tests/component_tests/thermostat/__init__.py b/tests/component_tests/thermostat/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/thermostat/config/presets.yaml b/tests/component_tests/thermostat/config/presets.yaml new file mode 100644 index 0000000000..956562ff63 --- /dev/null +++ b/tests/component_tests/thermostat/config/presets.yaml @@ -0,0 +1,53 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo_a + name: Thermo A + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C + - platform: thermostat + id: thermo_b + name: Thermo B + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C diff --git a/tests/component_tests/thermostat/test_presets.py b/tests/component_tests/thermostat/test_presets.py new file mode 100644 index 0000000000..129cb1587f --- /dev/null +++ b/tests/component_tests/thermostat/test_presets.py @@ -0,0 +1,28 @@ +"""Tests for the thermostat preset codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_presets_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Thermostats with the same presets share one PROGMEM table per kind.""" + main_cpp = generate_main(component_config_path("presets.yaml")) + + standard = re.findall( + r"static constexpr thermostat::ThermostatPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + custom = re.findall( + r"static constexpr thermostat::ThermostatCustomPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(standard) == 1 + assert len(custom) == 1 + for var in ("thermo_a", "thermo_b"): + assert f"{var}->set_preset_config({standard[0]}, 2);" in main_cpp + assert f"{var}->set_custom_preset_config({custom[0]}, 1);" in main_cpp + assert "climate::CLIMATE_MODE_HEAT" in main_cpp diff --git a/tests/component_tests/tmp102/__init__.py b/tests/component_tests/tmp102/__init__.py new file mode 100644 index 0000000000..61db1769a2 --- /dev/null +++ b/tests/component_tests/tmp102/__init__.py @@ -0,0 +1 @@ +"""TMP102 regression tests.""" diff --git a/tests/component_tests/tmp102/config/advanced.yaml b/tests/component_tests/tmp102/config/advanced.yaml new file mode 100644 index 0000000000..5d32255cb8 --- /dev/null +++ b/tests/component_tests/tmp102/config/advanced.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: false + conversion_rate: 1Hz + one_shot_mode: false + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 2 + temperature_high: 30 + temperature_low: 25 diff --git a/tests/component_tests/tmp102/config/alert.yaml b/tests/component_tests/tmp102/config/alert.yaml new file mode 100644 index 0000000000..3e1e42197d --- /dev/null +++ b/tests/component_tests/tmp102/config/alert.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + name: Alert diff --git a/tests/component_tests/tmp102/config/basic.yaml b/tests/component_tests/tmp102/config/basic.yaml new file mode 100644 index 0000000000..6326b0eaa2 --- /dev/null +++ b/tests/component_tests/tmp102/config/basic.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature diff --git a/tests/component_tests/tmp102/config/duplicate_alert.yaml b/tests/component_tests/tmp102/config/duplicate_alert.yaml new file mode 100644 index 0000000000..c56d0d96fa --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_alert.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + tmp102_id: chip + name: Alert One + - platform: tmp102 + tmp102_id: chip + name: Alert Two diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py new file mode 100644 index 0000000000..49f2629baf --- /dev/null +++ b/tests/component_tests/tmp102/test_config.py @@ -0,0 +1,121 @@ +"""Exercise TMP102 sensor validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import tmp102 as component +from esphome.components.tmp102.sensor import CONFIG_SCHEMA +from esphome.config import read_config +import esphome.config_validation as cv +from esphome.core import CORE + + +def test_basic_unchanged( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("basic.yaml")) + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_" not in defines + + +def test_advanced_options_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("advanced.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(3648, 7680, 6400)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_CONFIGURE" in defines + + +def test_alert_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("alert.yaml")) + assert "set_alert_binary_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_BINARY_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_alert_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_alert.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "Only one TMP102 binary_sensor may reference each temperature sensor" + in captured.out + ) + + +@pytest.mark.parametrize( + ("extended", "high", "low"), + [ + (False, 127.9375, -55), + (True, 150, -55), + (False, 30, 30), + ], +) +def test_valid_limits(extended: bool, high: float, low: float) -> None: + validated = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": extended, + "temperature_high": high, + "temperature_low": low, + } + ) + assert validated["temperature_high"] == high + assert validated["temperature_low"] == low + + +@pytest.mark.parametrize( + "config", + [ + {"temperature_high": float("nan")}, + {"temperature_low": float("inf")}, + {"temperature_low": -55.0625}, + {"temperature_high": 128}, + {"extended_mode": True, "temperature_high": 150.0625}, + {"temperature_high": 30, "temperature_low": 31}, + {"temperature_high": 74}, + {"temperature_low": 81}, + {"fault_queue": 3}, + ], +) +def test_invalid_limits(config: dict) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Temperature", **config}) + + +def test_precomputed_register_values() -> None: + config = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": True, + "conversion_rate": "0.25Hz", + "one_shot_mode": True, + "alert_polarity": "active_high", + "thermostat_mode": "interrupt", + "fault_queue": 6, + "temperature_high": 12.22, + "temperature_low": -12.22, + } + ) + assert component.build_configuration(config) == 0x1F10 + assert component.encode_temperature(config["temperature_high"], True) == 0x0620 + assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 + + +def test_defaults_match_chip_reset() -> None: + assert component.build_configuration({}) == 0x0080 diff --git a/tests/component_tests/uart/__init__.py b/tests/component_tests/uart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/uart/config/payload_tables.yaml b/tests/component_tests/uart/config/payload_tables.yaml new file mode 100644 index 0000000000..e93ab4754d --- /dev/null +++ b/tests/component_tests/uart/config/payload_tables.yaml @@ -0,0 +1,34 @@ +--- +esphome: + name: test + on_boot: + then: + - uart.write: [0x01, 0x02, 0x03] + - uart.write: !lambda return {0x09}; + - uart.write: "" + +esp32: + board: esp32dev + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 9600 + +switch: + - platform: uart + id: shared_switch + name: Shared Switch + data: [0x01, 0x02, 0x03] + - platform: uart + id: on_off_switch + name: On Off Switch + data: + turn_on: "ON" + turn_off: [0x04] + +button: + - platform: uart + id: shared_button + name: Shared Button + data: [0x01, 0x02, 0x03] diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py new file mode 100644 index 0000000000..2ba341136a --- /dev/null +++ b/tests/component_tests/uart/test_payload_tables.py @@ -0,0 +1,28 @@ +"""Tests for UART constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal payloads share one table across switch, button and write action.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}") + assert sorted(tables.values()) == sorted( + ["{0x01, 0x02, 0x03}", "{0x4F, 0x4E}", "{0x04}"] + ) + assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp + assert f"shared_button->set_data({shared}, 3);" in main_cpp + assert f"set_data_static({shared}, 3);" in main_cpp + assert "set_data_static(nullptr, 0);" in main_cpp + assert "set_data_template(" in main_cpp diff --git a/tests/component_tests/uart/test_unclocked.py b/tests/component_tests/uart/test_unclocked.py new file mode 100644 index 0000000000..bbd131037d --- /dev/null +++ b/tests/component_tests/uart/test_unclocked.py @@ -0,0 +1,57 @@ +"""Tests for marking UARTs whose bytes do not arrive with the timing of a serial line.""" + +from esphome.components import ble_nus, tcp_uart, uart, usb_cdc_acm +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_only_a_marked_uart_is_unclocked() -> None: + marked = ID("link", is_declaration=True) + assert uart.mark_unclocked(marked) is marked + assert uart.is_unclocked(ID("link")) + assert not uart.is_unclocked(ID("bus")) + + +def test_a_generated_id_is_found_once_named() -> None: + generated = uart.mark_unclocked( + ID(None, is_declaration=True, type=uart.UARTComponent) + ) + name = generated.resolve([]) + assert uart.is_unclocked(ID(name)) + + +def test_tcp_uart_is_unclocked() -> None: + tcp_uart.CONFIG_SCHEMA({CONF_ID: "link", "role": "server", "port": 6638}) + assert uart.is_unclocked(ID("link")) + + +def test_every_usb_uart_channel_is_unclocked( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={"variant": "ESP32S3"}) + # Its schema is built on import and needs the target. + from esphome.components import usb_uart + + schema = usb_uart.channel_schema(usb_uart._TYPES_BY_NAME["FT2232"]) + schema( + { + "channels": [ + {CONF_ID: "ch_a", "baud_rate": 9600}, + {CONF_ID: "ch_b", "baud_rate": 115200}, + ] + } + ) + assert uart.is_unclocked(ID("ch_a")) + assert uart.is_unclocked(ID("ch_b")) + + +def test_usb_cdc_acm_interface_is_unclocked() -> None: + usb_cdc_acm.INTERFACE_SCHEMA({CONF_ID: "cdc"}) + assert uart.is_unclocked(ID("cdc")) + + +def test_ble_nus_is_unclocked(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + ble_nus.CONFIG_SCHEMA({CONF_ID: "nus", "type": "uart"}) + assert uart.is_unclocked(ID("nus")) 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/api/__init__.py b/tests/components/api/__init__.py index bd84c8d9bb..79feb398bd 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042..d658da1c1f 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e..54aeb4b4ed 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST 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/bytebuffer/test_wrap_values.cpp b/tests/components/bytebuffer/test_wrap_values.cpp index 9a470d8a08..bcae7125bf 100644 --- a/tests/components/bytebuffer/test_wrap_values.cpp +++ b/tests/components/bytebuffer/test_wrap_values.cpp @@ -8,8 +8,7 @@ namespace esphome::bytebuffer { -// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce. -// Doubles are written as float literals, so they carry float precision. +// Typed values as ByteBuffer::wrap packs them; esp32_ble_server's value_bytes() is tested against the same bytes. TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector{0x12})); EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector{0x12, 0x00})); @@ -24,9 +23,9 @@ TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { (std::vector{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector{0x42, 0xf6, 0x33, 0x33})); EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector{0xcd, 0xcc, 0xcc, 0x3d})); - EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(), - (std::vector{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f})); - EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(), + EXPECT_EQ(ByteBuffer::wrap(double(0.1), LITTLE).get_data(), + (std::vector{0x9a, 0x99, 0x99, 0x99, 0x99, 0x99, 0xb9, 0x3f})); + EXPECT_EQ(ByteBuffer::wrap(double(2.5), BIG).get_data(), (std::vector{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})); } 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/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 521ae10e5c..d0058a634b 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,14 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SumBytes) { + static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum + EXPECT_EQ(sum_bytes(FRAME, 0), 0); + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME), 0x19), 0x00); // wraps at 8 bits + static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; 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/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 0000000000..56cfa10d51 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing 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/common.yaml b/tests/components/cse7761/common.yaml index 77b19957b4..ae3a642709 100644 --- a/tests/components/cse7761/common.yaml +++ b/tests/components/cse7761/common.yaml @@ -2,6 +2,10 @@ sensor: - platform: cse7761 voltage: name: CSE7761 Voltage + frequency: + name: CSE7761 Frequency + power_factor_1: + name: CSE7761 Power Factor 1 current_1: name: CSE7761 Current 1 current_2: @@ -10,3 +14,7 @@ sensor: name: CSE7761 Active Power 1 active_power_2: name: CSE7761 Active Power 2 + energy_1: + name: CSE7761 Energy 1 + energy_2: + name: CSE7761 Energy 2 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/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml index af8d44896b..294e7367fb 100644 --- a/tests/components/emontx/common-actions.yaml +++ b/tests/components/emontx/common-actions.yaml @@ -5,3 +5,21 @@ button: - emontx.send_command: id: test_emontx command: !lambda return "l"; + - platform: template + name: Pause emontx + on_press: + - if: + condition: + emontx.is_paused: test_emontx + then: + - emontx.resume: test_emontx + else: + - emontx.pause: test_emontx + - platform: template + name: Pause emontx explicit id + on_press: + - emontx.pause: + id: test_emontx + - delay: 1s + - emontx.resume: + id: test_emontx 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 20d3adf0dc..5d9fa09611 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -91,4 +92,103 @@ TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { EXPECT_EQ(this->lines_[0], "MSG:3"); } +TEST_F(EmonTxTest, LoopLeavesBytesUntouchedWhenPaused) { + this->uart_.push_rx("{\"P1\":123}\n"); + this->sut_.set_paused(true); + this->sut_.loop(); + + EXPECT_EQ(this->uart_.available(), std::strlen("{\"P1\":123}\n")); + EXPECT_TRUE(this->lines_.empty()); +} + +TEST_F(EmonTxTest, SendCommandIsSuppressedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.send_command("x"); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->sut_.set_paused(false); + this->sut_.send_command("x"); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "x\n"); +} + +// Neither the part before the pause nor the part after it may reach on_data +TEST_F(EmonTxTest, ResumeDiscardsInterruptedLine) { + this->uart_.push_rx("{\"P1\":4"); + this->sut_.loop(); + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + + this->uart_.push_rx("56}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("{\"P1\":789}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":789}"); +} + +TEST_F(EmonTxTest, ResumeDropsBacklogReceivedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n{\"P1\":2}\n{\"P1\":"); + this->sut_.set_paused(false); + + this->sut_.loop(); + EXPECT_EQ(this->uart_.available(), 0u); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("3}\n{\"P1\":4}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":4}"); +} + +TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n"); + this->sut_.set_paused(false); + + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":2}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + 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_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index b513c43234..01994e2d7a 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -110,6 +110,13 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: "Changed" + # Empty constants + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: "" + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: "" # Lambdas that read the trigger argument - ble_server.characteristic.set_value: id: test_change_characteristic diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d84..a26c5d35a3 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -44,3 +44,12 @@ canbus: break; // to be continued... } + +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(esp32_can_esp32_internal_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d, bus_err: %" PRIu32 ", arb_lost: %" PRIu32, + status.bus_off, status.tx_error_counter, status.rx_error_counter, + status.bus_error_count, status.arb_lost_count); diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml new file mode 100644 index 0000000000..74d93e6577 --- /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: true + power_down_peripherals: ${esp32_power_management_power_down_peripherals} + profiling: true + trace: true 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..2b25b2d23f --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "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..c571a2be0c --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 96MHZ + esp32_power_management_min_frequency: 32MHZ + esp32_power_management_power_down_peripherals: "true" + +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..2b25b2d23f --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/event/event_types_test.cpp b/tests/components/event/event_types_test.cpp new file mode 100644 index 0000000000..0bc99e1a8e --- /dev/null +++ b/tests/components/event/event_types_test.cpp @@ -0,0 +1,51 @@ +#include + +#include "esphome/components/event/event.h" + +namespace esphome::event::testing { + +static const char *const TYPES[] = {"pressed", "held"}; + +TEST(EventTypes, StaticTableIsViewed) { + Event event; + event.set_event_types_static(TYPES, 2); + EXPECT_EQ(event.get_event_types().data(), TYPES); + EXPECT_EQ(event.get_event_types().size(), 2u); +} + +TEST(EventTypes, CopiesAnotherEventAndResetsLastType) { + Event source; + source.set_event_types({"x", "y"}); + Event copy; + copy.set_event_types_static(TYPES, 2); + copy.trigger("held"); + ASSERT_TRUE(copy.has_event()); + copy.set_event_types(source.get_event_types()); + EXPECT_FALSE(copy.has_event()); + EXPECT_NE(copy.get_event_types().data(), TYPES); + EXPECT_NE(copy.get_event_types().data(), source.get_event_types().data()); + EXPECT_EQ(copy.get_event_types().size(), 2u); + EXPECT_STREQ(copy.get_event_types()[1], "y"); +} + +TEST(EventTypes, CopiesItsOwnList) { + Event event; + event.set_event_types({"a", "b"}); + event.set_event_types(event.get_event_types()); // copies before freeing the old list + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[1], "b"); +} + +TEST(EventTypes, CopiesFromFixedVector) { + FixedVector types; + types.init(2); + types.push_back("on"); + types.push_back("off"); + Event event; + event.set_event_types(types); + EXPECT_NE(event.get_event_types().data(), types.begin()); + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[0], "on"); +} + +} // namespace esphome::event::testing 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/haier/common.yaml b/tests/components/haier/common.yaml index 926a1d1b7a..ab39dfa61d 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -43,6 +43,12 @@ climate: level: DEBUG format: "Alarm deactivated. Code: %d. Message: \"%s\"" args: [code, message] + on_status_message: + then: + - logger.log: + level: DEBUG + format: "Status message: %u bytes" + args: [(unsigned) data_size] sensor: - platform: haier 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/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/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 18a7fd0913..952dd5f50f 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,7 @@ lvgl: id: lvgl_id rotation: 90 log_level: debug + check_args: true resume_on_input: true paused: true update_when_display_idle: true @@ -976,6 +977,16 @@ lvgl: - spinner: align: right_mid send_draw_task_events: true + event_trickle: true + state_trickle: true + radio_button: true + flex_in_new_track: !lambda return true; + - spinner: + align: top_mid + layout_1: true + layout_2: true + widget_1: !lambda return false; + widget_2: false - image: id: lv_image src: cat_image @@ -1766,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-idf6.esp32-p4-idf.yaml b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml new file mode 100644 index 0000000000..e0332caaed --- /dev/null +++ b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml @@ -0,0 +1,22 @@ +# LVGL only uses the PPA to draw on the ESP32-P4 with ESP-IDF 6.0 or later +esp32: + framework: + version: 6.0.1 + +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C + +lvgl: + widgets: + - obj: + bg_color: 0x2040C0 + widgets: + - label: + text: PPA + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml index 5fd9370255..a7a8d13ffc 100644 --- a/tests/components/lvgl/test.esp32-p4-idf.yaml +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -3,6 +3,7 @@ display: model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C lvgl: byte_order: little_endian + check_args: true rotation: 90 psram: diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 22d051da8e..3c4825b067 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -1,5 +1,5 @@ display: - - platform: sdl + - platform: snapshot id: sdl0 auto_clear_enabled: false dimensions: @@ -11,11 +11,6 @@ display: width: 480 height: 480 -touchscreen: - - platform: sdl - display: sdl0 - sdl_id: sdl0 - lvgl: - id: lvgl_0 default_font: space16 @@ -84,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/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 2e88f3e5c6..a35839d9f2 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -42,6 +42,17 @@ canbus: // to be continued... } +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(mcp2515_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d", + status.bus_off, + status.tx_error_counter, + status.rx_error_counter + ); + button: - platform: template name: MCP2515 Send diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index 9ac1056ba4..2b9df607c8 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -13,6 +13,7 @@ microphone: micro_wake_word: microphone: echo_microphone task_stack_in_psram: true + vad: on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml new file mode 100644 index 0000000000..bddab918c2 --- /dev/null +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + micro_wake_word: !include common.yaml + +micro_wake_word: + prefer_internal_ram: true diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index a1b7b75423..8806d8046d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -3,5 +3,7 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + micro_wake_word: !include common.yaml -<<: !include common.yaml +micro_wake_word: + prefer_internal_ram: true diff --git a/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml new file mode 100644 index 0000000000..bbb4a7269a --- /dev/null +++ b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml @@ -0,0 +1,18 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +display: + - platform: mipi_dsi + model: JC1060P470-V2 + enable_pin: GPIO22 + +#light: + #- platform: mipi_dsi + #id: backlight_id + +psram: diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp index 84106eb0b7..a2fb33a2a3 100644 --- a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -8,6 +8,9 @@ namespace esphome::mk2pvrouter::testing { namespace { +// The parser works on raw frame bytes; the groups below are written as text for readability. +const uint8_t *as_bytes(const char *str) { return reinterpret_cast(str); } + class TestListener final : public Mk2PVRouterListener { public: explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} @@ -25,7 +28,9 @@ class Mk2PVRouterTest : public ::testing::Test { void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. - template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + template void process_(const char (&group)[N]) { + this->sut_.process_group_(as_bytes(group), as_bytes(group) + N - 1); + } TestableMk2PVRouter sut_; TestListener listener_{"P1"}; @@ -40,22 +45,22 @@ TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. const char grp[] = "P1\t1234\t="; - EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); + EXPECT_EQ(this->sut_.calculate_crc_(as_bytes(grp), sizeof(grp) - 1), '='); } TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { const char grp[] = "P1\t1234\t="; - EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_TRUE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { const char grp[] = "P1\t1234\t!"; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { const char grp[] = ""; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp))); } TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { 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/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index 8c55546ce6..a1a91e9c67 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -26,6 +26,8 @@ text_sensor: name: "Network Key" pan_id: name: "PAN ID" + mesh_local_eid: + name: "Mesh Local EID" ext_pan_id: name: "Extended PAN ID" 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/pipsolar/common.yaml b/tests/components/pipsolar/common.yaml index 819764a7ea..883b9d289d 100644 --- a/tests/components/pipsolar/common.yaml +++ b/tests/components/pipsolar/common.yaml @@ -204,6 +204,12 @@ sensor: pv_charging_power: id: inverter0_pv_charging_power name: inverter0_pv_charging_power + total_pv_generated_energy: + id: inverter0_total_pv_generated_energy + name: inverter0_total_pv_generated_energy + total_output_load_energy: + id: inverter0_total_output_load_energy + name: inverter0_total_output_load_energy switch: - platform: pipsolar @@ -241,3 +247,9 @@ text_sensor: last_qflag: id: inverter0_last_qflag name: inverter0_last_qflag + last_qet: + id: inverter0_last_qet + name: inverter0_last_qet + last_qlt: + id: inverter0_last_qlt + name: inverter0_last_qlt diff --git a/tests/components/pmsx003/common-luftmy.yaml b/tests/components/pmsx003/common-luftmy.yaml new file mode 100644 index 0000000000..538b2481a4 --- /dev/null +++ b/tests/components/pmsx003/common-luftmy.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: pmsx003 + type: LUFTMY + pm_1_0_std: + name: PM 1.0 Standard Atmospheric Concentration + pm_2_5_std: + name: PM 2.5 Standard Atmospheric Concentration + pm_10_0_std: + name: PM 10.0 Standard Atmospheric Concentration + pm_0_3um: + name: Particulate Count >0.3um + pm_0_5um: + name: Particulate Count >0.5um + pm_1_0um: + name: Particulate Count >1.0um + pm_2_5um: + name: Particulate Count >2.5um + pm_5_0um: + name: Particulate Count >5.0um + pm_10_0um: + name: Particulate Count >10.0um + update_interval: 30s diff --git a/tests/components/pmsx003/test.esp32-idf.yaml b/tests/components/pmsx003/test.esp32-idf.yaml index b415125e84..9e5b478a31 100644 --- a/tests/components/pmsx003/test.esp32-idf.yaml +++ b/tests/components/pmsx003/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.esp8266-ard.yaml b/tests/components/pmsx003/test.esp8266-ard.yaml index 96ab4ef6ac..8e7bd3ebe8 100644 --- a/tests/components/pmsx003/test.esp8266-ard.yaml +++ b/tests/components/pmsx003/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.rp2040-ard.yaml b/tests/components/pmsx003/test.rp2040-ard.yaml index b28f2b5e05..58779cc095 100644 --- a/tests/components/pmsx003/test.rp2040-ard.yaml +++ b/tests/components/pmsx003/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml new file mode 100644 index 0000000000..c0c648e038 --- /dev/null +++ b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + pmsx003: !include common-luftmy.yaml diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 714f8ab571..f104557265 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,22 @@ 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. - request_protocol("hob2hood") + 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 # AUTO_LOAD is not resolved by the unit test build. 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 new file mode 100644 index 0000000000..b003318a1f --- /dev/null +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -0,0 +1,118 @@ +#include +#include +#include +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/haier_protocol.h" +#include "esphome/components/remote_base/mirage_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const uint8_t HAIER_CODE[] = {0xA6, 0x12, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; +const uint8_t MIRAGE_CODE[] = {0x56, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; +const uint8_t AEHA_CODE[] = {0x80, 0x00, 0x00, 0x00, 0x00}; +constexpr uint16_t AEHA_ADDRESS = 0x2002; + +// Captured from the struct encoders before they gained the pointer and length overloads +const RawTimings HAIER_GOLDEN = { + 3100, -3100, 3100, -4400, 540, -1650, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -1650, 540, -1650, 540, -580, + 540, -1650, 540, -1650, 540, -1650, 540, -1650, 540, -580, 540, -1650, 540}; +const RawTimings MIRAGE_GOLDEN = { + 8360, -4248, 554, -545, 554, -1592, 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -545, 554, -545, 554}; +const RawTimings AEHA_GOLDEN = { + 3400, -1700, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -1275, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425}; + +std::vector haier_code() { return {std::begin(HAIER_CODE), std::end(HAIER_CODE)}; } +std::vector mirage_code() { return {std::begin(MIRAGE_CODE), std::end(MIRAGE_CODE)}; } +std::vector aeha_code() { return {std::begin(AEHA_CODE), std::end(AEHA_CODE)}; } + +// The Haier and Mirage decoders expect an item after the last mark (pre-existing) +RawTimings with_trailing_space(RawTimings timings) { + timings.push_back(-10000); + return timings; +} + +// 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) { + auto decoded = P().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + EXPECT_EQ(decoded.value_or(typename P::ProtocolData{}).data, code); +} + +} // namespace + +TEST(IrCodeEncodersTest, HaierPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(HAIER_CODE, sizeof(HAIER_CODE), haier_code(), HAIER_GOLDEN); +} + +TEST(IrCodeEncodersTest, MiragePointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code(), MIRAGE_GOLDEN); +} + +TEST(IrCodeEncodersTest, AehaPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(AEHA_CODE, sizeof(AEHA_CODE), aeha_code(), AEHA_GOLDEN, AEHA_ADDRESS); +} + +TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { + RemoteTransmitData haier, mirage, aeha; + HaierProtocol().encode(&haier, HaierData{haier_code()}); + MirageProtocol().encode(&mirage, MirageData{mirage_code()}); + AEHAData aeha_data; + aeha_data.address = AEHA_ADDRESS; + aeha_data.data = aeha_code(); + AEHAProtocol().encode(&aeha, aeha_data); + EXPECT_EQ(haier.get_data(), HAIER_GOLDEN); + EXPECT_EQ(mirage.get_data(), MIRAGE_GOLDEN); + EXPECT_EQ(aeha.get_data(), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, RoundTripDecodes) { + expect_decodes_to(with_trailing_space(HAIER_GOLDEN), haier_code()); + expect_decodes_to(with_trailing_space(MIRAGE_GOLDEN), mirage_code()); + expect_decodes_to(AEHA_GOLDEN, aeha_code()); + auto aeha = AEHAProtocol().decode(RemoteReceiveData(AEHA_GOLDEN, 25, TOLERANCE_MODE_PERCENTAGE)); + EXPECT_EQ(aeha.value_or(AEHAData{}).address, AEHA_ADDRESS); +} + +} // namespace esphome::remote_base::testing 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/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp new file mode 100644 index 0000000000..e96d8b9d01 --- /dev/null +++ b/tests/components/remote_base/keeloq_test.cpp @@ -0,0 +1,177 @@ +#include +#include "esphome/components/remote_base/keeloq_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_US = 380; + +KeeloqData frame(uint16_t suffix, uint8_t suffix_bits) { + KeeloqData data{}; + data.encrypted = 0xd19ef0a9; + data.address = 0x116ea01; + data.command = 0x08; + data.suffix = suffix; + data.suffix_bits = suffix_bits; + return data; +} + +RawTimings encode(const KeeloqData &data) { + RemoteTransmitData tx; + KeeloqProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark and merges the guard into the previous space. +RawTimings as_received(RawTimings timings, bool drop_tail = false) { + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + RawTimings merged; + for (int32_t item : timings) { + 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; +} + +optional decode(const RawTimings &timings) { + return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(uint16_t suffix, uint8_t suffix_bits, bool drop_tail = false) { + auto decoded = decode(as_received(encode(frame(suffix, suffix_bits)), drop_tail)); + ASSERT_TRUE(decoded.has_value()) << "suffix 0x" << std::hex << suffix << "/" << std::dec << int(suffix_bits); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->command, 0x08); + EXPECT_EQ(decoded->encrypted, 0xd19ef0a9u); + EXPECT_TRUE(decoded->repeat); + EXPECT_FALSE(decoded->vlow); + EXPECT_EQ(decoded->suffix, suffix); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); +} + +// 0xA5 sends 1, 0, 1 first. Replace the space of one of those bits while the other bits still follow. +optional decode_with_suffix_space(uint8_t bit, uint32_t space_us) { + RawTimings timings = as_received(encode(frame(0xA5, 8))); + timings[timings.size() - 2 * 8 + 2 * bit + 1] = -static_cast(space_us); + return decode(timings); +} + +} // namespace + +TEST(KeeloqProtocolTest, PlainWordHasNoSuffix) { expect_round_trip(0, 0); } + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheGuardSwallowsTheLastSpace) { + expect_round_trip(0x00, 8); + expect_round_trip(0x80, 8); + expect_round_trip(0xA5, 8); + expect_round_trip(0x0000, 16); + expect_round_trip(0x8000, 16); + expect_round_trip(0, 1); + expect_round_trip(1, 1); +} + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheCaptureEndsAtIdle) { + expect_round_trip(0x00, 8, true); + expect_round_trip(0x80, 8, true); + expect_round_trip(0x8000, 16, true); + expect_round_trip(1, 1, true); +} + +TEST(KeeloqProtocolTest, ExtraBitsThatEndAtIdleStayASuffix) { + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 4); + } +} + +TEST(KeeloqProtocolTest, FollowingPwmIsNotASuffix) { + // Drop only the guard item. The repeat bit keeps its own space, then more PWM follows. + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_FALSE(timings.empty()); + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + timings.push_back(static_cast(3 * BIT_US)); + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 0); + } +} + +TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { + // A 1 bit with the space of a 0 bit, and a 0 bit with the space of a 1 bit. + for (const auto &[bit, space_us] : {std::pair{2, BIT_US}, {1, 2 * BIT_US}}) { + auto decoded = decode_with_suffix_space(bit, space_us); + ASSERT_TRUE(decoded.has_value()) << "bit " << int(bit); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->suffix, 0) << "bit " << int(bit); + EXPECT_EQ(decoded->suffix_bits, 0) << "bit " << int(bit); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { + // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + for (uint8_t suffix_bits : {0, 8}) { + RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); + const size_t repeat = timings.size() - 2 * suffix_bits - 2; + timings[repeat] = static_cast(2 * BIT_US); + if (suffix_bits > 0) { + timings[repeat + 1] = -static_cast(BIT_US); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << int(suffix_bits); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + EXPECT_EQ(decoded->suffix, suffix_bits > 0 ? 0xA5 : 0); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { + // What follows the gap is the next frame. + auto decoded = decode_with_suffix_space(2, 4 * BIT_US); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0x5); + EXPECT_EQ(decoded->suffix_bits, 3); + } +} + +} // 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 4dadec6a01..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: @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command"] + format: "on_keeloq: %lu %lu %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] on_haier: 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 776a03e680..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 @@ -316,3 +369,170 @@ button: on_press: - remote_transmitter.transmit_hob2hood: command: light_on + - platform: template + name: KeeLoq HCS301 + on_press: + remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + command: 0x02 + code: 0xd19ef0a9 + - platform: template + name: KeeLoq with suffix + on_press: + remote_transmitter.transmit_keeloq: + address: 0x116ea01 + command: 0x08 + 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/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/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 0000000000..17f553996e --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 0000000000..3853bf3e8d --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !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/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-ipv6.bk72xx-ard.yaml b/tests/components/socket/test-ipv6.bk72xx-ard.yaml new file mode 100644 index 0000000000..6075623b54 --- /dev/null +++ b/tests/components/socket/test-ipv6.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + network_enable_ipv6: "true" + +packages: + socket: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp new file mode 100644 index 0000000000..d730e3a4a6 --- /dev/null +++ b/tests/components/socket/test_multicast_host.cpp @@ -0,0 +1,104 @@ +#include + +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// join_multicast_group — EINVAL on malformed input +// ========================================================================= + +TEST(JoinMulticastGroup, MalformedIPv4YieldsEINVAL) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "not-an-ip")); + EXPECT_EQ(errno, EINVAL); +} + +TEST(JoinMulticastGroup, MalformedIPv6YieldsEINVAL) { + // ':' present but parse fails — EINVAL regardless of USE_NETWORK_IPV6 + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "gg::not-ipv6")); + EXPECT_EQ(errno, EINVAL); +} + +// ========================================================================= +// join_multicast_group — IPv4 IP_ADD_MEMBERSHIP +// ========================================================================= + +TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 99; + // A host with no multicast route (e.g. an isolated container) cannot join + if (join_multicast_group(sock.get(), "239.0.60.53", &if_index)) { + EXPECT_EQ(if_index, 0u); + } else { + EXPECT_EQ(if_index, 99u); + } +} + +TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { + // 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); +} + +// ========================================================================= +// join_multicast_group — IPv6 and set_ipv6_multicast_if +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 0; + errno = 0; + if (join_multicast_group(sock.get(), "ff12::cafe", &if_index)) { + EXPECT_GT(if_index, 0u); + } 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; + } +} + +TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + if (!set_ipv6_multicast_if(sock.get(), 0) && errno != EADDRNOTAVAIL) { + GTEST_SKIP() << "Host refused IPV6_MULTICAST_IF, errno " << errno; + } +} + +TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { + auto sock_join = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + auto sock_send = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_join, nullptr); + ASSERT_NE(sock_send, nullptr); + uint32_t if_index = 0; + 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 + +} // namespace esphome::socket::testing + +#endif // USE_HOST 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/tmp102/__init__.py b/tests/components/tmp102/__init__.py new file mode 100644 index 0000000000..f86177c0f9 --- /dev/null +++ b/tests/components/tmp102/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) + cg.add_define("USE_TMP102_CONFIGURE") + cg.add_define("USE_TMP102_BINARY_SENSOR") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 049a8067da..1e5f1f456c 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -2,3 +2,21 @@ sensor: - platform: tmp102 i2c_id: i2c_bus name: TMP102 Temperature + - platform: tmp102 + id: tmp102_extended_temperature + name: TMP102 Extended Temperature + i2c_id: i2c_bus + address: 0x49 + extended_mode: true + one_shot_mode: true + conversion_rate: 8Hz + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 6 + temperature_high: 140°C + temperature_low: 120°C + +binary_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Alert diff --git a/tests/components/tmp102/test.esp32-idf.yaml b/tests/components/tmp102/test.esp32-idf.yaml index b47e39c389..d879c5b4ae 100644 --- a/tests/components/tmp102/test.esp32-idf.yaml +++ b/tests/components/tmp102/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.esp8266-ard.yaml b/tests/components/tmp102/test.esp8266-ard.yaml index 4a98b9388a..10be01238c 100644 --- a/tests/components/tmp102/test.esp8266-ard.yaml +++ b/tests/components/tmp102/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.rp2040-ard.yaml b/tests/components/tmp102/test.rp2040-ard.yaml index 319a7c71a6..b364443f57 100644 --- a/tests/components/tmp102/test.rp2040-ard.yaml +++ b/tests/components/tmp102/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp new file mode 100644 index 0000000000..cd06d8a83d --- /dev/null +++ b/tests/components/tmp102/test_tmp102.cpp @@ -0,0 +1,309 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/tmp102/tmp102.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tmp102::testing { + +class RegisterBus final : public i2c::I2CBus { + public: + std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; + int fail_read{-1}; + int fail_write{-1}; + int fail_select{-1}; + unsigned transactions{0}; + unsigned writes{0}; + uint8_t pointer{0}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + ++this->transactions; + if (size != 0) { + if (data[0] >= this->registers.size() || data[0] == this->fail_select) + return i2c::ERROR_NOT_ACKNOWLEDGED; + this->pointer = data[0]; + if (size == 3) { + if (this->pointer == this->fail_write) + return i2c::ERROR_NOT_ACKNOWLEDGED; + ++this->writes; + this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + } + } + if (out_size != 0) { + if (out_size != 2 || this->pointer == this->fail_read) + return i2c::ERROR_NOT_ACKNOWLEDGED; + out[0] = this->registers[this->pointer] >> 8; + out[1] = this->registers[this->pointer]; + } + return i2c::ERROR_OK; + } +}; + +class TMP102Test : public ::testing::Test { + protected: + RegisterBus bus_; + TMP102Component chip_; + binary_sensor::BinarySensor alert_; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + char name[] = "tmp102-regression-tests"; + char friendly_name[] = "TMP102 tests"; + App.pre_setup(name, 23, friendly_name, 12); + this->chip_.set_i2c_bus(&this->bus_); + this->chip_.set_i2c_address(0x48); + this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); + App.scheduler.call(millis()); + App.~Application(); + } + + static void run_timers(uint32_t duration) { + App.scheduler.call(millis()); + delay(duration); + App.scheduler.call(millis()); + } + + void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { + this->chip_.set_configuration(config, high, low); + } +}; + +TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + this->chip_.update(); + run_timers(55); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.registers, original); +} + +TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { + for (bool configured_extended : {false, true}) { + this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, + configured_extended ? 0x2580 : 0x4B00); + this->chip_.setup(); + const auto writes = this->bus_.writes; + for (auto raw : {0x1900, 0x0C81, 0xFF00, 0xFF81}) { + this->bus_.registers[0] = raw; + this->chip_.update(); + EXPECT_FLOAT_EQ(this->chip_.state, raw & 0x8000 ? -1 : 25); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.writes, writes); + } + } +} + +TEST_F(TMP102Test, WritableConfigurationFields) { + this->configure(0x1FD0, 0x2800, 0x2580); + this->chip_.setup(); + EXPECT_EQ(this->bus_.registers[1], 0x1FD0); + EXPECT_EQ(this->bus_.registers[3], 0x2800); + EXPECT_EQ(this->bus_.registers[2], 0x2580); +} + +TEST_F(TMP102Test, AlertEntityPreservesHardwareAndUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + for (uint16_t config : {0x60A0, 0x6080, 0x64A0, 0x6480}) { + this->bus_.registers[1] = config; + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x20) == bool(config & 0x400)); + EXPECT_EQ(this->bus_.registers[1], config); + } + EXPECT_EQ(this->bus_.writes, 0); +} + +TEST_F(TMP102Test, FailedTemperatureOrAlertReadInvalidatesAlert) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + for (int reg : {0, 1}) { + this->bus_.fail_read = reg; + this->chip_.update(); + run_timers(55); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); + this->bus_.fail_read = -1; + this->chip_.update(); + run_timers(55); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_TRUE(this->alert_.has_state()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + for (uint16_t config : {0x0080, 0x00A0, 0x0480, 0x04A0}) { + this->configure(config & ~0x0020); + this->chip_.setup(); + this->bus_.registers[1] = config; + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x0020) == bool(config & 0x0400)); + EXPECT_FALSE(this->chip_.status_has_warning()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertInvalidatesDuringRecoveryAndRepublishes) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->chip_.update(); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + run_timers(45); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_TRUE(this->alert_.state); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + const auto configured = this->bus_.registers; + this->chip_.update(); + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_TRUE(this->chip_.status_has_warning()); + for (size_t reg = 1; reg < 4; ++reg) + EXPECT_EQ(this->bus_.registers[reg], configured[reg]); + this->chip_.update(); + EXPECT_EQ(this->publications_, 2); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, RecoveryRetriesFailedWritesAndDetectsThresholdOnlyChange) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[3] = 0; + this->bus_.fail_write = 3; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.fail_write = -1; + this->chip_.update(); + EXPECT_EQ(this->bus_.registers[3], 0x5000); + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, ConfigurationReadFailuresDoNotPublishOrOverwriteRegisters) { + this->configure(); + this->chip_.setup(); + for (int reg : {1, 2, 3}) { + const auto publications = this->publications_; + const auto writes = this->bus_.writes; + this->bus_.fail_read = reg; + this->chip_.update(); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->publications_, publications); + EXPECT_EQ(this->bus_.writes, writes); + this->bus_.fail_read = -1; + } +} + +TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { + this->configure(); + this->chip_.setup(); + const auto writes = this->bus_.writes; + this->bus_.registers[1] ^= 0xE020; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->alert_.publish_state(false); + this->bus_.fail_write = 1; + this->chip_.update(); + run_timers(45); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); +} + +TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.pointer = 3; + run_timers(45); + EXPECT_EQ(this->publications_, 1); + EXPECT_FLOAT_EQ(this->chip_.state, 25); +} + +TEST_F(TMP102Test, LimitsAreOnlyCheckedWhenConfigurationMatchesResetDefault) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[2] = 0; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.registers[2], 0x4B00); + + this->configure(0x00C0); + this->chip_.setup(); + this->bus_.registers[2] = 0; + const auto transactions = this->bus_.transactions; + const auto writes = this->bus_.writes; + this->chip_.update(); + EXPECT_EQ(this->bus_.transactions - transactions, 2); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { + for (int reg : {1, 2, 3}) { + TMP102Component failed; + failed.set_i2c_bus(&this->bus_); + failed.set_i2c_address(0x48); + failed.set_configuration(0x0080, 0x5000, 0x4B00); + this->bus_.fail_write = reg; + failed.setup(); + EXPECT_TRUE(failed.is_failed()); + } +} + +} // namespace esphome::tmp102::testing + +#endif diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 0000000000..b65e8a659f --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing 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/ufm01/__init__.py b/tests/components/ufm01/__init__.py new file mode 100644 index 0000000000..19febacc61 --- /dev/null +++ b/tests/components/ufm01/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The optional features are define-gated; emit them so the tests cover those paths + async def to_code_testing(config): + cg.add_define("USE_UFM01_METER_ID") + cg.add_define("USE_UFM01_SOFTWARE_VERSION") + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/ufm01/benchmark.yaml b/tests/components/ufm01/benchmark.yaml new file mode 100644 index 0000000000..f78cdfe033 --- /dev/null +++ b/tests/components/ufm01/benchmark.yaml @@ -0,0 +1,29 @@ +# Defines USE_SENSOR; the tests attach their own instances +sensor: + - platform: template + id: bench_flow + name: "Flow" + - platform: template + id: bench_temperature + name: "Temperature" + +# Defines USE_TEXT_SENSOR +text_sensor: + - platform: template + id: bench_meter_id + name: "Meter ID" + - platform: template + id: bench_software_version + name: "Software version" + +# One instance so __init__.py's to_code runs and emits the feature defines +uart: + - id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null + +ufm01: + - id: ufm01_bench + uart_id: uart_bus diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h index 1582358700..e04e09b765 100644 --- a/tests/components/ufm01/common.h +++ b/tests/components/ufm01/common.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,17 +10,36 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/ufm01/ufm01.h" +#include "esphome/core/application.h" +#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::ufm01::testing { static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; static constexpr uint8_t COMMAND_ACK = 0xE5; +// Matches PASSIVE_FAIL_ESCALATE_COUNT in ufm01.cpp +static constexpr uint8_t ESCALATE_COUNT = 8; + +using Command = std::array; +static constexpr Command ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr Command PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; +static constexpr Command CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr Command RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr Command READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr Command READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr Command GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // UART mock with a byte queue for read-side simulation. class QueuedMockUART : public uart::UARTComponent { @@ -31,6 +51,10 @@ class QueuedMockUART : public uart::UARTComponent { this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); } + template void enqueue(const std::array &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + void enqueue(std::initializer_list data) { for (uint8_t byte : data) this->rx_queue.push_back(byte); @@ -71,15 +95,37 @@ class TestableUFM01 : public UFM01Component { public: void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } - bool process_active_stream() { return this->process_active_stream_(); } + static uint32_t now() { return App.get_loop_component_start_time(); } - PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + bool process_active_stream() { return this->process_active_stream_(now()); } + + ReadResult continue_passive_read() { return this->continue_passive_read_(now()); } bool consume_ack() { return this->consume_ack_(); } - void start_passive_read() { this->start_passive_read_(); } + void start_passive_read() { this->start_passive_read_(now()); } - void loop_startup() { this->loop_startup_(); } + void loop_startup() { this->loop_startup_(now()); } + + void loop_active_stream() { this->loop_active_stream_(now()); } + + void loop_passive_poll() { this->loop_passive_poll_(now()); } + + void loop_pending_clear_action() { this->loop_pending_clear_action_(now()); } + + bool clear_pending() const { return this->clear_pending_; } + + bool pending_clear_sent() const { return this->pending_clear_sent_; } + + bool meter_id_published() const { return this->meter_id_published_; } + + void start_software_version_read() { this->start_software_version_read_(now()); } + + ReadResult continue_software_version_read() { return this->continue_software_version_read_(now()); } + + bool software_version_published() const { return this->software_version_published_; } + + void set_last_software_version_attempt_ms(uint32_t ms) { this->last_software_version_attempt_ms_ = ms; } OperatingMode operating_mode() const { return this->operating_mode_; } @@ -87,20 +133,52 @@ class TestableUFM01 : public UFM01Component { int32_t read_index() const { return this->read_index_; } - size_t passive_index() const { return this->passive_index_; } + uint8_t passive_index() const { return this->passive_index_; } uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + uint8_t consecutive_passive_failures() const { return this->consecutive_passive_failures_; } + + void set_operating_mode(OperatingMode mode) { this->operating_mode_ = mode; } + + // Expects a reply without the meter ID, as start_passive_read_() picks when no meter ID sensor is set void prepare_passive_read() { this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->passive_start_ms_ = now(); + } + + void begin_pending_passive_read() { + this->prepare_passive_read(); + this->passive_read_pending_ = true; } void init_wait_phase() { this->operating_mode_ = OperatingMode::STARTUP; this->startup_phase_ = StartupPhase::WAIT; this->startup_wait_ms_ = 60000; - this->phase_start_ms_ = millis(); + this->phase_start_ms_ = now(); + } + + void begin_active_stream() { + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->last_valid_frame_ms_ = now(); + this->passive_read_pending_ = false; + this->read_index_ = 0; + } + + void begin_active_wait_frame() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::ACTIVE_WAIT_FRAME; + this->phase_start_ms_ = now(); + } + + void begin_post_reset_wait() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::POST_RESET_WAIT; + this->phase_start_ms_ = now(); + this->software_version_published_ = false; } void reset_state() { @@ -108,24 +186,59 @@ class TestableUFM01 : public UFM01Component { this->last_valid_frame_ms_ = 0; this->passive_index_ = 0; this->passive_read_pending_ = false; + this->operating_mode_ = OperatingMode::STARTUP; + this->consecutive_passive_failures_ = 0; + this->meter_id_published_ = false; + this->software_version_published_ = false; + this->software_version_read_pending_ = false; + this->software_version_failures_ = 0; + this->meter_id_invalid_logged_ = false; + this->last_software_version_attempt_ms_ = 0; + this->startup_phase_ = StartupPhase::WAIT; + this->clear_pending_ = false; + this->pending_clear_sent_ = false; } }; +template inline uint8_t checksum(const std::array &data, size_t begin, size_t end) { + uint8_t sum = 0; + for (size_t i = begin; i < end; ++i) + sum += data[i]; + return sum; +} + inline std::array make_active_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; frame[1] = FRAME_START_BYTE_2; + // Datasheet example device ID 2307140001 + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; frame[15] = FRAME_FLAG_INSTANT_FLOW; frame[21] = FRAME_FLAG_RESERVED_SECTION; frame[24] = FRAME_FLAG_TEMP; frame[31] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 30; ++i) - sum += frame[i]; - frame[30] = sum; + frame[30] = checksum(frame, 0, 30); return frame; } +inline std::array make_software_version_response() { + // Datasheet example version 23380315 + std::array response{}; + response[0] = COMMAND_ACK; + response[1] = 0x15; + response[2] = 0x03; + response[3] = 0x38; + response[4] = 0x23; + response[5] = checksum(response, 1, 5); + response[6] = FRAME_STOP_BYTE; + return response; +} + inline std::array make_passive_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; @@ -133,22 +246,93 @@ inline std::array make_passive_frame() { frame[9] = FRAME_FLAG_INSTANT_FLOW; frame[15] = FRAME_FLAG_TEMP; frame[22] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += frame[i]; - frame[21] = sum; + frame[21] = checksum(frame, 0, 21); + return frame; +} + +inline std::array make_passive_with_id_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2_WITH_ID; + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; + frame[8] = 0x0A; + // Non-measurement bytes ensure a shifted instant-flow offset is detected. + for (size_t i = 17; i < 22; ++i) + frame[i] = 0x99; + frame[22] = FRAME_FLAG_INSTANT_FLOW; + frame[23] = 0x34; + frame[24] = 0x12; + frame[27] = 0x00; + frame[31] = FRAME_FLAG_TEMP; + frame[32] = 0x50; + frame[33] = 0x23; + frame[38] = FRAME_STOP_BYTE; + frame[37] = checksum(frame, 0, 37); return frame; } class UFM01Test : public ::testing::Test { protected: + // Non-zero so a frame received at the start time still counts as a valid timestamp + static constexpr uint32_t START_MS = 1000; + void SetUp() override { this->mock_uart_.clear_rx(); this->mock_uart_.written_data.clear(); this->ufm01_.set_mock_uart(&this->mock_uart_); this->ufm01_.reset_state(); + this->set_clock(START_MS); } + // Publishes the time the component reads as the loop clock + void set_clock(uint32_t ms) { + this->now_ = ms; + LoopBlockingGuard clock{nullptr, nullptr, ms}; + } + + void advance(uint32_t ms) { this->set_clock(this->now_ + ms); } + + void expect_written(const Command &command) { + EXPECT_EQ(this->mock_uart_.written_data, std::vector(command.begin(), command.end())); + } + + void fail_passive_poll() { + this->ufm01_.begin_pending_passive_read(); + this->advance(2000); + this->ufm01_.loop_passive_poll(); + } + + void make_active_stream_stale() { + this->ufm01_.begin_active_stream(); + this->advance(6000); + } + +#ifdef USE_SENSOR + void attach_flow_and_temperature_sensors() { + this->ufm01_.set_flow_sensor(&this->flow_sensor_); + this->ufm01_.set_temperature_sensor(&this->temperature_sensor_); + this->flow_sensor_.publish_state(1.23f); + this->temperature_sensor_.publish_state(20.5f); + } + + sensor::Sensor flow_sensor_; + sensor::Sensor temperature_sensor_; +#endif + + void attach_diagnostic_text_sensors() { + this->ufm01_.set_meter_id_text_sensor(&this->meter_id_text_sensor_); + this->ufm01_.set_software_version_text_sensor(&this->software_version_text_sensor_); + } + + text_sensor::TextSensor meter_id_text_sensor_; + text_sensor::TextSensor software_version_text_sensor_; + + uint32_t now_{0}; QueuedMockUART mock_uart_; TestableUFM01 ufm01_; }; diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml index c818dc2965..29bc05dfab 100644 --- a/tests/components/ufm01/common.yaml +++ b/tests/components/ufm01/common.yaml @@ -28,3 +28,17 @@ binary_sensor: flow_rate_out_of_range: id: flow_rate_out_of_range name: "Flow rate out of range" + +text_sensor: + - platform: ufm01 + meter_id: + id: ufm01_meter_id + name: "Meter ID" + software_version: + id: ufm01_software_version + name: "Software version" + +script: + - id: ufm01_clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_component diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml index 34041cc223..a2d4469798 100644 --- a/tests/components/ufm01/test.esp32-idf.yaml +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml index 195f4b41b5..7826cdc44a 100644 --- a/tests/components/ufm01/test.esp8266-ard.yaml +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml index 13b3284fe3..1483674ddc 100644 --- a/tests/components/ufm01/test.rp2040-ard.yaml +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/ufm01_clear_action_test.cpp b/tests/components/ufm01/ufm01_clear_action_test.cpp new file mode 100644 index 0000000000..a1d10b1926 --- /dev/null +++ b/tests/components/ufm01/ufm01_clear_action_test.cpp @@ -0,0 +1,113 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ClearAccumulatedFlowSendsCommand) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); + EXPECT_TRUE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnAck) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.enqueue({COMMAND_ACK}); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnTimeout) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->advance(400); + this->ufm01_.loop_pending_clear_action(); + ASSERT_TRUE(this->ufm01_.clear_pending()); + + this->advance(200); + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, DuplicateClearRequestJoinsThePendingOne) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.written_data.clear(); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->ufm01_.loop_pending_clear_action(); + EXPECT_FALSE(this->ufm01_.clear_pending()); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingPassiveRead) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.loop_passive_poll(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingSoftwareVersionRead) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.start_software_version_read(); + this->mock_uart_.written_data.clear(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_software_version_response()); + this->ufm01_.loop_active_stream(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsDuringStartup) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_TRUE(this->ufm01_.clear_pending()); + EXPECT_FALSE(this->ufm01_.pending_clear_sent()); +} + +TEST_F(UFM01Test, ClearTimesOutWhileWaitingForIdle) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + this->advance(20000); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_diagnostics_test.cpp b/tests/components/ufm01/ufm01_diagnostics_test.cpp new file mode 100644 index 0000000000..5898b56563 --- /dev/null +++ b/tests/components/ufm01/ufm01_diagnostics_test.cpp @@ -0,0 +1,173 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ActiveFramePublishesMeterIdOnce) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + + this->mock_uart_.enqueue(frame); + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, PassiveFrameWithIdUsesDocumentedMeasurementOffsets) { + this->attach_diagnostic_text_sensors(); + this->attach_flow_and_temperature_sensors(); + this->ufm01_.start_passive_read(); + this->expect_written(READ_SENSOR_DATA_WITH_ID); + + this->mock_uart_.enqueue(make_passive_with_id_frame()); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + EXPECT_NEAR(this->flow_sensor_.get_state(), 0.01234f, 0.000001f); + EXPECT_NEAR(this->temperature_sensor_.get_state(), 23.5f, 0.001f); +} +#endif + +TEST_F(UFM01Test, InvalidPassiveFrameWithIdIsRejected) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_passive_read(); + auto frame = make_passive_with_id_frame(); + frame[37] ^= 0xFF; + this->mock_uart_.enqueue(frame); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.meter_id_published()); +} + +TEST_F(UFM01Test, SoftwareVersionResponseDecoded) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, SoftwareVersionReadSkipsLeftoverStreamBytes) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + // Tail of an active frame that was still on the wire when the command was sent + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin() + 20, frame.end())); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, InvalidSoftwareVersionBcdFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + auto response = make_software_version_response(); + response[2] = 0xFA; + response[5] = checksum(response, 1, 5); + this->mock_uart_.enqueue(response); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, SoftwareVersionReadTimeoutFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->advance(1900); + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_PENDING); + + this->advance(100); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, StartupReadsSoftwareVersionBeforeActiveMode) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.begin_post_reset_wait(); + this->advance(3000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SOFTWARE_VERSION_WAIT_REPLY); + this->expect_written(GET_SOFTWARE_VERSION); + + this->mock_uart_.enqueue(make_software_version_response()); + this->mock_uart_.written_data.clear(); + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::ACTIVE_WAIT_FRAME); + this->expect_written(ACTIVE_MODE); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, AlreadyActiveStartupRequestsSoftwareVersion) { + this->attach_diagnostic_text_sensors(); + this->mock_uart_.enqueue(make_active_frame()); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamRetriesSoftwareVersionRead) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.set_last_software_version_attempt_ms(this->now_); + this->advance(29000); + this->ufm01_.loop_active_stream(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->advance(2000); + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamGivesUpSoftwareVersionAfterRepeatedFailures) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + for (int attempt = 0; attempt < 3; ++attempt) { + this->ufm01_.loop_active_stream(); + this->expect_written(GET_SOFTWARE_VERSION); + this->mock_uart_.written_data.clear(); + this->advance(2000); + this->ufm01_.loop_active_stream(); + this->advance(30000); + } + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, InvalidMeterIdLeavesSensorUnknown) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + frame[2] = 0xAB; + frame[30] = checksum(frame, 0, 30); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + + EXPECT_FALSE(this->ufm01_.meter_id_published()); + EXPECT_FALSE(this->meter_id_text_sensor_.has_state()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 3b1b148d50..8e659ce40d 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -4,7 +4,7 @@ namespace esphome::ufm01::testing { TEST_F(UFM01Test, ValidActiveFrameAccepted) { auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -14,7 +14,7 @@ TEST_F(UFM01Test, ValidActiveFrameAccepted) { TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -23,7 +23,7 @@ TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { auto frame = make_active_frame(); frame[30] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_FALSE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -32,10 +32,10 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { auto frame = make_passive_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -43,29 +43,29 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { TEST_F(UFM01Test, InvalidPassiveChecksumFails) { auto frame = make_passive_frame(); frame[21] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({0x00, 0x01, 0x02}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp index 6b7feb0f1b..c6d5a7f4eb 100644 --- a/tests/components/ufm01/ufm01_startup_test.cpp +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -1,14 +1,7 @@ #include "common.h" -#include "esphome/core/component.h" - namespace esphome::ufm01::testing { -TEST(UFM01SetupPriority, IsLate) { - TestableUFM01 ufm01; - EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); -} - TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); @@ -19,8 +12,7 @@ TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { - auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(make_active_frame()); this->ufm01_.init_wait_phase(); this->ufm01_.loop_startup(); @@ -32,12 +24,117 @@ TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { TEST_F(UFM01Test, StartPassiveReadSendsCommand) { this->ufm01_.start_passive_read(); - ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); - EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); - EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); - EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StartupActiveWaitTimeoutSendsSetPassiveMode) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SET_PASSIVE_WAIT_ACK); + this->expect_written(PASSIVE_MODE); +} + +TEST_F(UFM01Test, StartupSetPassiveWaitAckStartsPassiveRead) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + this->ufm01_.loop_startup(); + this->mock_uart_.enqueue({COMMAND_ACK}); + this->mock_uart_.written_data.clear(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::PASSIVE_WAIT_REPLY); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StaleActiveStreamSendsSetPassiveMode) { + // Leftover stream noise should be flushed before SET_PASSIVE_MODE + this->mock_uart_.enqueue({0x3C, 0x32, 0x00}); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + EXPECT_EQ(this->ufm01_.read_index(), 0); + this->expect_written(PASSIVE_MODE); + EXPECT_EQ(this->mock_uart_.available(), 0u); // RX flushed +} + +TEST_F(UFM01Test, StalePassivePollWaitsOneIntervalAndDiscardsLateAck) { + this->make_active_stream_stale(); + this->ufm01_.loop_active_stream(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + this->mock_uart_.written_data.clear(); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->advance(500); + this->ufm01_.loop_passive_poll(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + + this->advance(500); + this->ufm01_.loop_passive_poll(); + this->expect_written(READ_SENSOR_DATA_NO_ID); + EXPECT_EQ(this->mock_uart_.available(), 0u); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, StaleActiveStreamPublishesNanToFlowAndTemperature) { + this->attach_flow_and_temperature_sensors(); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresPublishNanBeforeReset) { + this->attach_flow_and_temperature_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT; ++i) + this->fail_passive_poll(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} +#endif + +TEST_F(UFM01Test, PassivePollSuccessClearsFailureCount) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (int i = 0; i < 3; ++i) + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.consecutive_passive_failures(), 3u); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.loop_passive_poll(); + + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresEscalateToReset) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT - 1; ++i) { + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL) << "i=" << static_cast(i); + } + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), ESCALATE_COUNT - 1); + + this->mock_uart_.written_data.clear(); + this->fail_passive_poll(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::STARTUP); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::RESET_WAIT_ACK); + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + this->expect_written(RESET_DEVICE); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/validate.host.yaml b/tests/components/ufm01/validate.host.yaml new file mode 100644 index 0000000000..19c0739f0b --- /dev/null +++ b/tests/components/ufm01/validate.host.yaml @@ -0,0 +1,16 @@ +esphome: + name: ufm01-validate + +host: + +logger: + +packages: + ufm01: !include common.yaml + +uart: + id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null 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/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 8ef4957072..4851ec7dea 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,3 +1,11 @@ +esphome: + area: + id: testing_area + name: Testing Area + project: + name: esphome.test + version: "1.1" + binary_sensor: - platform: template name: "Garage Door Open 1" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 84b79bb84f..2b6fc3b6c5 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -16,6 +16,9 @@ binary_sensor: name: "Garage Door Open 13" endpoint: 2 use_device_type: false + on_press: + then: + - zigbee.report sensor: - platform: template @@ -26,6 +29,7 @@ sensor: endpoint: 1 use_device_type: true cluster: device_class + report: enable - platform: template name: "Light Sensor" lambda: return 100.0; @@ -58,6 +62,8 @@ switch: zigbee: model: zigbee_test + manufacturer: test + version: 1 sleepy: true polling_interval: 2s power_source: BATTERY diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index 2da6570618..a99f6bd92e 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -3,6 +3,8 @@ packages: zigbee: model: zigbee_test + manufacturer: test + version: 1 wipe_on_boot: true on_join: then: 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_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/uart_mock_ufm01.yaml b/tests/integration/fixtures/uart_mock_ufm01.yaml new file mode 100644 index 0000000000..0f72225c08 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ufm01.yaml @@ -0,0 +1,153 @@ +esphome: + name: uart-mock-ufm01 + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates + actions: + - action: clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_dev + - lambda: |- + id(clear_count) += 1; + id(clear_completions).publish_state(id(clear_count)); +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ufm01's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 2400 + port: /dev/null + +globals: + # The simulated meter streams active frames only after SET_ACTIVE_MODE + - id: streaming + type: bool + initial_value: "false" + # Set by CLEAR_ACCUMULATED_FLOW; later frames report zero accumulated flow + - id: cleared + type: bool + initial_value: "false" + - id: clear_count + type: int + initial_value: "0" + +# Simulated UFM-01: every command is one 7 byte write +uart_mock: + id: mock_uart + baud_rate: 2400 + parity: EVEN + on_tx: + - then: + - lambda: |- + if (id(silent).state || data.size() != 7) { + return; + } + static const std::vector ACK = {0xE5}; + switch (data[3]) { + case 0x5D: // RESET_DEVICE + id(streaming) = false; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5C: // SET_ACTIVE_MODE (00) or SET_PASSIVE_MODE (01) + id(streaming) = data[4] == 0x00; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5A: // CLEAR_ACCUMULATED_FLOW + id(cleared) = true; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5B: // READ_SENSOR_DATA without meter ID + if (data[4] != 0x0F) { + break; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0x3C, 0x64, // start + 0x0A, 0x78, 0x56, 0x02, 0x00, 0x00, 0x00, // accumulated flow 25.678 L + 0x0B, 0x00, 0x67, 0x05, 0x00, 0x00, // flow 0.567 m3/h + 0x0D, 0x25, 0x19, 0x00, // temperature 19.25 C + 0x00, 0x00, // ST1, ST2 + 0x3C, 0x16, // checksum, stop + }); + break; + case 0x5E: // GET_SOFTWARE_VERSION: ACK, version 23380315 (BCD, low byte first), checksum, stop + id(mock_uart).inject_to_rx_buffer(std::vector{0xE5, 0x15, 0x03, 0x38, 0x23, 0x73, 0x16}); + break; + } + +interval: + - interval: 1s + then: + - if: + condition: + lambda: "return id(streaming) && !id(silent).state;" + then: + - lambda: |- + // Meter ID 2307140001 (BCD, low byte first), flow 1.2345 m3/h, temperature 23.5 C + std::vector frame{ + 0x3C, 0x32, // start + 0x01, 0x00, 0x14, 0x07, 0x23, 0x01, // meter ID, reserved + 0x0A, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // accumulated flow 12.345 L + 0x0B, 0x50, 0x34, 0x12, 0x00, 0x00, // flow + 0x0C, 0x00, 0x00, // reserved section + 0x0D, 0x50, 0x23, 0x00, // temperature + 0x00, 0x00, // ST1, ST2 + 0x4E, 0x16, // checksum, stop + }; + if (id(cleared)) { + // Accumulated flow 0 m3: unit flag 0x1A, zero digits, checksum follows + frame[8] = 0x1A; + std::fill(frame.begin() + 9, frame.begin() + 15, 0x00); + frame[30] = 0xF5; + } + id(mock_uart).inject_to_rx_buffer(frame); + +ufm01: + id: ufm01_dev + uart_id: mock_uart + +sensor: + - platform: ufm01 + accumulated_flow: + name: "Accumulated Flow" + flow: + name: "Flow" + temperature: + name: "Temperature" + - platform: template + id: clear_completions + name: "Clear Completions" + update_interval: never + +text_sensor: + - platform: ufm01 + meter_id: + name: "Meter ID" + software_version: + name: "Software Version" + +switch: + # The meter stops streaming and answers nothing + - platform: template + id: silent + name: "Silent" + optimistic: true + turn_on_action: + - lambda: "id(streaming) = false;" + +button: + # The stream stops while the meter still answers commands + - platform: template + name: "Pause Stream" + on_press: + - lambda: "id(streaming) = false;" + - platform: template + name: "Resume Stream" + on_press: + - lambda: "id(streaming) = true;" 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 09111764c4..9c85679432 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,168 +1,179 @@ { - "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_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_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_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_lvgl_headless_render.py b/tests/integration/test_lvgl_headless_render.py index 1c60e49604..5a668a2302 100644 --- a/tests/integration/test_lvgl_headless_render.py +++ b/tests/integration/test_lvgl_headless_render.py @@ -30,7 +30,7 @@ EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd4865509 # Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was # generated against. A version bump can shift anti-aliasing enough to change the hash even though # nothing is actually wrong -- if this test fails, check that first before regenerating the hash. -EXPECTED_LVGL_VERSION = "9.5.0" +EXPECTED_LVGL_VERSION = "9.6.0" @pytest.mark.asyncio 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_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_uart_mock_ufm01.py b/tests/integration/test_uart_mock_ufm01.py new file mode 100644 index 0000000000..c2c2f08f52 --- /dev/null +++ b/tests/integration/test_uart_mock_ufm01.py @@ -0,0 +1,399 @@ +"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART. + +All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands, +answers sensor data and software version reads, and streams active frames each second after +SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still +answers, and "Resume Stream" starts the stream again. + +test_uart_mock_ufm01_startup: + Reset, software version read and active mode go out in order, the active frames publish flow, + temperature and accumulated flow, and the meter ID and software version publish once. +test_uart_mock_ufm01_stale_recovery: + A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling, + whose answers publish; a resumed stream switches back to the active stream and polling stops. +test_uart_mock_ufm01_escalation: + Eight unanswered passive polls send a reset and run the startup sequence again; values publish + once the meter answers, and the diagnostics are not published again. +test_uart_mock_ufm01_clear_action: + The clear command is sent once the UART is idle while the automation continues at once; the + meter's ACK, or its absence, is only logged, and a second request joins the pending one. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import math +import re + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + SensorState, + SwitchInfo, + TextSensorState, + UserService, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16 +RESET_DEVICE = "FE:FE:11:5D:CB:28:16" +SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16" +SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16" +READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16" +GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16" +CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16" + +# Values the fixture's frames encode +ACTIVE_FLOW = 1.2345 +ACTIVE_TEMPERATURE = 23.5 +ACTIVE_ACCUMULATED = 12.345 +PASSIVE_FLOW = 0.567 +PASSIVE_TEMPERATURE = 19.25 +PASSIVE_ACCUMULATED = 25.678 +METER_ID = "2307140001" +SOFTWARE_VERSION = "23380315" + +ACTIVE_STARTED = "UFM-01 active stream started" +ACTIVE_RESUMED = "UFM-01 active stream resumed" +STALE = "Active stream stale, switching to passive polling" +ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01" +CLEAR_ACKED = "Clear accumulated flow acknowledged" +CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged" + +# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval +STARTUP_TIMEOUT = 8.0 +ACTIVE_STALE_S = 5.0 + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +class _Log: + """Record TX frames and ufm01 log lines with their arrival time, and wait for lines.""" + + def __init__(self) -> None: + self.events: list[tuple[float, str]] = [] + self._awaited: list[tuple[str, asyncio.Future[float]]] = [] + + def on_line(self, line: str) -> None: + line = ANSI_ESCAPE.sub("", line) + if "uart_mock" in line and "TX " in line and "bytes: " in line: + text = line.split("bytes: ", 1)[1].strip() + elif "[ufm01" in line: + text = line + else: + return + now = asyncio.get_running_loop().time() + self.events.append((now, text)) + for wanted, future in self._awaited: + if wanted in text and not future.done(): + future.set_result(now) + + def next(self, text: str) -> asyncio.Future[float]: + """Return a future for the time of the next event containing ``text``.""" + future: asyncio.Future[float] = asyncio.get_running_loop().create_future() + self._awaited.append((text, future)) + return future + + @property + def tx(self) -> list[str]: + return [text for _, text in self.events if text.startswith("FE:")] + + def index(self, text: str, start: int = 0) -> int: + return next( + i + for i, (_, event) in enumerate(self.events) + if i >= start and text in event + ) + + def tx_between(self, start: int, end: int) -> list[str]: + return [text for _, text in self.events[start:end] if text.startswith("FE:")] + + +class _Meter: + """Entities and state history of the running fixture.""" + + def __init__( + self, client: APIClient, entities: list[EntityInfo], services: list[UserService] + ) -> None: + self.client = client + self.services = services + self.by_id = {entity.object_id: entity for entity in entities} + self.waiter = StateWaiter() + self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id} + self._names = {entity.key: entity.object_id for entity in entities} + self.helper = InitialStateHelper(entities) + + def on_state(self, state: EntityState) -> None: + if ( + isinstance(state, (SensorState, TextSensorState)) + and not state.missing_state + ): + self.states[self._names[state.key]].append(state.state) + self.waiter.on_state(state) + + def expect( + self, name: str, check: Callable[[float | str], bool], timeout: float + ) -> Awaitable[EntityState]: + key = self.by_id[name].key + return self.waiter.expect( + lambda s: s.key == key and not s.missing_state and check(s.state), + timeout=timeout, + label=name, + ) + + def press(self, name: str) -> None: + entity = self.by_id[name] + assert isinstance(entity, ButtonInfo) + self.client.button_command(entity.key) + + def set_silent(self, silent: bool) -> None: + entity = self.by_id["silent"] + assert isinstance(entity, SwitchInfo) + self.client.switch_command(entity.key, silent) + + def all_values(self, name: str) -> list[float | str]: + """Return every value ``name`` held, including one already set at connect time.""" + initial = self.helper.initial_states[self.by_id[name].key] + return ([] if initial.missing_state else [initial.state]) + self.states[name] + + +async def _connect(client: APIClient) -> _Meter: + entities, services = await client.list_entities_services() + meter = _Meter(client, entities, services) + client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state)) + try: + await meter.helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return meter + + +def _is_nan(value: float | str) -> bool: + return isinstance(value, float) and math.isnan(value) + + +def _approx(expected: float) -> Callable[[float | str], bool]: + return lambda value: value == pytest.approx(expected, abs=1e-4) + + +def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]: + """Arm waits for the next flow, temperature and accumulated flow from an active frame.""" + return [ + meter.expect("flow", _approx(ACTIVE_FLOW), timeout), + meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout), + meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout), + ] + + +async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None: + try: + await asyncio.gather(*waits) + except TimeoutError as err: + pytest.fail(f"{err}; states: {meter.states}") + + +async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float: + try: + return await asyncio.wait_for(future, timeout) + except TimeoutError: + pytest.fail(f"Timed out; log: {log.events}") + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_startup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Startup reaches the active stream, publishes the decoded values and the diagnostics once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + values = _active_values(meter, STARTUP_TIMEOUT) + await _wait_for(log, started, STARTUP_TIMEOUT) + await _gather(meter, values) + # Two more stream frames + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE] + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_stale_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stopped stream falls back to passive polling and a resumed stream ends it.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + stale = log.next(STALE) + flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2) + temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2) + polled = [ + meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4), + meter.expect( + "temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4 + ), + meter.expect( + "accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4 + ), + ] + meter.press("pause_stream") + paused_at = asyncio.get_running_loop().time() + stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2) + # The last frame came up to 1 s before the pause + assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5 + await _gather(meter, [flow_unknown, temperature_unknown]) + await _gather(meter, polled) + + stale_index = log.index(STALE) + assert log.tx_between(stale_index, len(log.events))[:2] == [ + SET_PASSIVE_MODE, + READ_SENSOR_DATA, + ] + # Unknown first, then the passive answers + assert _is_nan(meter.states["flow"][-2]) + assert _is_nan(meter.states["temperature"][-2]) + + resumed = log.next(ACTIVE_RESUMED) + meter.press("resume_stream") + values = _active_values(meter, 3.0) + await _wait_for(log, resumed, 3.0) + await _gather(meter, values) + # Two more stream frames without any poll in between + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + assert READ_SENSOR_DATA not in log.tx_between( + log.index(ACTIVE_RESUMED), len(log.events) + ) + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_escalation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Eight unanswered passive polls reset the meter and rerun the startup sequence.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + escalated = log.next(ESCALATED) + meter.set_silent(True) + # Stale after 5 s, then eight polls that each time out after 1 s + flow_count = len(meter.states["flow"]) + await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4) + + restarted = log.next(ACTIVE_STARTED) + values = _active_values(meter, STARTUP_TIMEOUT) + meter.set_silent(False) + await _wait_for(log, restarted, STARTUP_TIMEOUT) + await _gather(meter, values) + + stale_index = log.index(STALE) + escalated_index = log.index(ESCALATED, stale_index) + assert ( + log.tx_between(stale_index, escalated_index) + == [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8 + ) + # A reset (resent once if the meter was still silent), then active mode; the software + # version is already known, so it is not read again + after = log.tx_between( + escalated_index, log.index(ACTIVE_STARTED, escalated_index) + ) + assert after in ( + [RESET_DEVICE, SET_ACTIVE_MODE], + [RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE], + ), after + # Unknown when the stream went stale and again on the re-init, nothing in between; + # a frame already in flight when the meter went silent may come first + flow = meter.states["flow"][flow_count:] + first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value)) + assert _is_nan(flow[first_unknown + 1]), flow + assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow + + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_clear_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The clear command is sent once the UART is idle; the automation continues at once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + acked = log.next(CLEAR_ACKED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + # A second entity listing would re-send the initial states and duplicate the first completion + (clear,) = meter.services + # Normally still inside the startup wait; a slow runner may already be streaming + queued_during_startup = not started.done() + + completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT) + zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2) + await client.execute_service(clear, {}) + await _wait_for(log, acked, STARTUP_TIMEOUT) + await _gather(meter, [completed, zeroed]) + + # Queued during startup, sent only once the active stream started + if queued_during_startup: + assert CLEAR_ACCUMULATED_FLOW not in log.tx_between( + 0, log.index(ACTIVE_STARTED) + ) + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1 + assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED) + + # Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout + sent = log.next(CLEAR_ACCUMULATED_FLOW) + not_acked = log.next(CLEAR_NOT_ACKED) + completed = meter.expect("clear_completions", _approx(3), 3.0) + meter.set_silent(True) + await asyncio.sleep(0.2) + await client.execute_service(clear, {}) + await client.execute_service(clear, {}) + sent_at = await _wait_for(log, sent, 2.0) + not_acked_at = await _wait_for(log, not_acked, 2.0) + await _gather(meter, [completed]) + assert not_acked_at - sent_at >= 0.4 + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2 + assert meter.states["clear_completions"] == [1.0, 2.0, 3.0] 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/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/__init__.py b/tests/unit_tests/components/remote_base/__init__.py new file mode 100644 index 0000000000..d6233f0230 --- /dev/null +++ b/tests/unit_tests/components/remote_base/__init__.py @@ -0,0 +1 @@ +"""Unit tests for remote_base config helpers.""" diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py new file mode 100644 index 0000000000..bd0f817dd1 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -0,0 +1,45 @@ +"""KeeLoq suffix is a transmit key and has to fit in its width.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_SUFFIX, + CONF_SUFFIX_BITS, + KEELOQ_SCHEMA, + _keeloq_suffix_fits, +) +import esphome.config_validation as cv +from esphome.core import Lambda + + +def test_binary_sensor_rejects_suffix() -> None: + with pytest.raises(cv.Invalid): + KEELOQ_SCHEMA( + {"address": 0x1, "code": 0x2, CONF_SUFFIX: 1, CONF_SUFFIX_BITS: 8} + ) + + +def test_plain_suffix_must_fit() -> None: + fitted = {"address": 0x1, "code": 0x2, CONF_SUFFIX: 0xA5, CONF_SUFFIX_BITS: 8} + assert _keeloq_suffix_fits(fitted)[CONF_SUFFIX] == 0xA5 + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + _keeloq_suffix_fits({**fitted, CONF_SUFFIX: 0x100}) + + +def test_lambda_suffix_is_not_checked_here() -> None: + config = {"address": 0x1, "code": 0x2, CONF_SUFFIX: "id(x)", CONF_SUFFIX_BITS: 4} + assert _keeloq_suffix_fits(config)[CONF_SUFFIX] == "id(x)" + + +def test_transmit_action_checks_the_width() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1, "code": 0x2, CONF_SUFFIX_BITS: 8} + assert schema({**base, CONF_SUFFIX: 0xA5})[CONF_SUFFIX] == 0xA5 + assert isinstance( + schema({**base, CONF_SUFFIX: Lambda("return 0x100;")})[CONF_SUFFIX], Lambda + ) + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + schema({**base, CONF_SUFFIX: 0x100}) 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 3cd70c082d..f2d4628044 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -36,12 +36,15 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_no_deferring_actions, + validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression +from esphome.types import ConfigType from esphome.util import Registry, RegistryEntry @@ -213,6 +216,164 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_has_non_synchronous_actions_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """An option that shares a name with a deferring action is not that action.""" + actions = [{"logger.log": {"message": "hello", "delay": "1s"}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_nested_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Option keys in nested option dicts are not looked up as action names either.""" + actions = [{"logger.log": {"settings": {"wait_until": {"condition": {}}}}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_option_named_like_action_inside_then( + mock_registry: dict[str, RegistryEntry], +) -> None: + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": {"message": "hello", "delay": "1s"}}], + } + } + ] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_in_else( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Action lists in any option of an action are searched, not only then:.""" + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": "hello"}], + "else": [{"delay": "1s"}], + } + } + ] + assert has_non_synchronous_actions(actions) is True + + +def test_has_non_synchronous_actions_automation_dict( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A single automation, as validate_automation(single=True) returns it.""" + assert has_non_synchronous_actions({CONF_THEN: [{"delay": "1s"}]}) is True + assert has_non_synchronous_actions({CONF_THEN: [{"logger.log": "hi"}]}) is False + + +def test_has_non_synchronous_actions_set_playlist_delay() -> None: + """speaker_source.set_playlist_delay is synchronous despite its delay: option.""" + import esphome.components.speaker_source.media_player # noqa: F401 # registers the action + + actions = [ + { + "speaker_source.set_playlist_delay": { + CONF_ID: "player", + "pipeline": 0, + "delay": 1000, + } + } + ] + assert has_non_synchronous_actions(actions) is False + + +def test_validate_no_deferring_actions_accepts_synchronous( + mock_registry: dict[str, RegistryEntry], +) -> None: + value = [{CONF_THEN: [{"logger.log": "hello"}]}] + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_accepts_lambda_form( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A returning lambda has no action list, so there is nothing to reject.""" + value = Lambda("return true;") + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_rejects_deferring( + mock_registry: dict[str, RegistryEntry], +) -> None: + validator = validate_no_deferring_actions("on_test", "the test data") + with pytest.raises( + cv.Invalid, + match=r"not allowed in on_test: the test data is only valid while", + ): + validator([{CONF_THEN: [{"logger.log": "hello"}, {"delay": "1s"}]}]) + + +def test_validate_no_deferring_actions_default_data( + mock_registry: dict[str, RegistryEntry], +) -> None: + with pytest.raises(cv.Invalid, match=r"on_test: the trigger data is only valid"): + validate_no_deferring_actions("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_accepts_synchronous() -> None: + result = validate_synchronous_automation("on_test")( + [{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}] + ) + assert len(result) == 1 + + +def test_validate_synchronous_automation_rejects_deferring() -> None: + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_rejects_nested_deferring() -> None: + actions = [ + { + "if": { + "condition": {"lambda": Lambda("return true;")}, + CONF_THEN: [{"delay": "1s"}], + } + } + ] + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: actions}]) + + +def test_validate_synchronous_automation_passes_options() -> None: + """extra_schema and single are passed through to validate_automation().""" + validator = validate_synchronous_automation( + "on_test", extra_schema={cv.Optional("retry"): cv.string}, single=True + ) + result = validator( + {"retry": "yes", CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]} + ) + assert isinstance(result, dict) + assert result["retry"] == "yes" + with pytest.raises(cv.Invalid, match="Deferring actions"): + validator({CONF_THEN: [{"delay": "1s"}]}) + + +def test_validate_synchronous_automation_passes_extra_validators() -> None: + """extra_validators is passed through to validate_automation() and runs on each automation.""" + seen: list[ConfigType] = [] + + def extra_validator(value: ConfigType) -> ConfigType: + seen.append(value) + return value + + validator = validate_synchronous_automation( + "on_test", extra_validators=extra_validator + ) + validator([{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}]) + assert len(seen) == 1 + assert CONF_THEN in seen[0] + + def _build_forwarder( automation_name: str, args: list[tuple[str, str]], diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 8a2510cf0a..6a08676784 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: 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_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 6fdd197c95..9529e44800 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,6 +4,12 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE, ID, EsphomeError @@ -887,6 +893,31 @@ class TestSharedProgmemArray: assert str(array) == "table_2" +class TestProgmemString: + @staticmethod + def _set_platform(platform: str) -> None: + CORE.config = {} + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_esp8266_shares_one_array_per_text(self) -> None: + self._set_platform(PLATFORM_ESP8266) + a = cg.shared_progmem_string("level") + b = cg.shared_progmem_string("level") + c = cg.shared_progmem_string('say "hi"') + assert a is b + assert str(a) == "progmem_str" + assert str(c) == "progmem_str_2" + assert [str(st) for st in CORE.global_statements] == [ + 'static constexpr char progmem_str[] PROGMEM = "level";', + 'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";', + ] + + def test_other_platforms_return_the_literal(self) -> None: + self._set_platform(PLATFORM_ESP32) + assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"' + assert not CORE.global_statements + + def test_extern_progmem_array_is_an_externally_linked_global() -> None: array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) assert str(array) == "esphome::api::API_TABLE" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b8330f159..3b10c9cb3c 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,9 @@ 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 def test_upload_program_serial_esp32( @@ -2223,6 +2229,384 @@ def test_upload_program_ota_allow_plaintext_upload( } +@pytest.mark.parametrize( + "ota_conf", + [ + { + CONF_ENCRYPTION: { + CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "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 == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +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"] + + +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, @@ -2889,6 +3273,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, @@ -6445,6 +6845,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_preferences.py b/tests/unit_tests/test_preferences.py index 677eeee7f2..a1ab78bfbf 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -147,3 +147,47 @@ def test_rtc_rejected_on_unsupported_platform() -> None: def test_flash_allowed_on_unsupported_platform() -> None: _set_platform(PLATFORM_RP2040) assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_supported_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is True + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_supported_false_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is False + + +@pytest.mark.parametrize( + ("platform", "expected"), + [(PLATFORM_ESP8266, True), (PLATFORM_RP2040, False)], +) +def test_rtc_supported_per_platform(platform: str, expected: bool) -> None: + _set_platform(platform) + assert preferences.rtc_supported() is expected + + +def test_validate_storage_accepts_flash_everywhere() -> None: + _set_platform(PLATFORM_RP2040) + assert preferences.validate_storage("flash") == preferences.STORAGE_FLASH + assert preferences.validate_storage("FLASH") == preferences.STORAGE_FLASH + + +def test_validate_storage_accepts_rtc_where_supported() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.validate_storage("rtc") == preferences.STORAGE_RTC + + +def test_validate_storage_rejects_rtc_without_rtc_memory() -> None: + _set_esp32(VARIANT_ESP32C2) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_storage("rtc") + + +def test_validate_storage_rejects_unknown_value() -> None: + _set_esp32(VARIANT_ESP32) + with pytest.raises(cv.Invalid): + preferences.validate_storage("nvs") 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..24a431b0c0 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.""" @@ -1901,9 +1892,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 +1935,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 +1963,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 +2074,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 +2227,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"}