diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..d68bb93ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -477,7 +477,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate @@ -1448,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1647,7 +1647,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1710,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json @@ -1740,13 +1740,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..c0991a6308f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..ba0404011be 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..95d8c49c23e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -439,6 +439,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index eb777a6a834..aa84cdddde9 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -1,10 +1,11 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::bedjet { -static const char *const TAG = "bedjet"; +ESPHOME_LOG_TAG(TAG, "bedjet"); /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f6173..b0fac5b1218 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -4,7 +4,7 @@ namespace esphome::ble_client { -const char *const Automation::TAG = "ble_client.automation"; +const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266 } // namespace esphome::ble_client diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 501556d3c40..45c3e5b260f 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bme280_i2c { -static const char *const TAG = "bme280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor"); class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index a19203ff0a0..f2ec93176ef 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp280_i2c { -static const char *const TAG = "bmp280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor"); /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index 126ffd6a606..24929c7d8b0 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp581_base/bmp581_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp581_i2c { -static const char *const TAG = "bmp581_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor"); /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 84e539e5f25..9278546ee94 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::chsc6x { -static const char *const TAG = "chsc6x.touchscreen"; +ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen"); static const uint8_t CHSC6X_REG_STATUS = 0x00; static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 84b561c7345..72dda4e9854 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst816 { -static const char *const TAG = "cst816.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst816.touchscreen"); static const uint8_t REG_STATUS = 0x00; static const uint8_t REG_TOUCH_NUM = 0x02; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h index 17050e2429a..5a8e043d654 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst9220 { -static const char *const TAG = "cst9220.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen"); // The CST92xx family uses 16-bit (big-endian) register addresses. static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), - cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - if config[CONF_LOCK_ON_REBOOT]: - cg.add(var.set_lock_on_reboot(True)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken - /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. - void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; - bool lock_on_reboot_{false}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 688e7164566..67a0cf5f233 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -24,3 +24,11 @@ WaveshareB( data_rate="10MHz", minimum_update_interval="1s", ) + +WaveshareB( + "waveshare-2.66in-b", + width=152, + height=296, + data_rate="10MHz", + minimum_update_interval="15s", +) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..ae299370cd7 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -49,6 +54,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..d84b58c5b0c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index a20419eceb1..7a3e84f56ca 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,11 +3,12 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" #include namespace esphome::ezo { -static const char *const TAG = "ezo.sensor"; +ESPHOME_LOG_TAG(TAG, "ezo.sensor"); enum EzoCommandType : uint8_t { EZO_READ = 0, diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 2803b439156..3efebabd885 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -3,4 +3,5 @@ DOMAIN = "ili9xxx" DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 64f87c167c1..5928eb5771f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -220,8 +220,10 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'ili9xxx' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 2529e54021b..77cee44ae68 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -2,12 +2,13 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "ili9xxx_defines.h" #include "ili9xxx_init.h" namespace esphome::ili9xxx { -static const char *const TAG = "ili9xxx"; +ESPHOME_LOG_TAG(TAG, "ili9xxx"); const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 enum ILI9XXXColorMode { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4da8a6a1a30..75f926d865e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,7 +214,7 @@ enum class Orientation : uint8_t { }; class LvglComponent final : public PollingComponent { - constexpr static const char *const TAG = "lvgl"; + constexpr static const char *const TAG = "lvgl"; // NOLINT: lvgl does not build for ESP8266 public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index d6790caa35d..e4985c91fa1 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -19,7 +19,7 @@ using alloc_string_t = std::basic_string, RAMAlloca * @tparam V The type of the value in the mapping. Should be a basic type or pointer. */ -static const char *const TAG = "mapping"; +ESPHOME_LOG_TAG(TAG, "mapping"); template class Mapping { public: diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..bc431599248 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. OpenThread hands its services to the SRP client once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and CONF_OPENTHREAD not in CORE.config + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4397c101fc5..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,57 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_(const char *network) { + // With no interface connected, keep the last value instead of flapping to wifi + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +215,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..06d10d2d818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,13 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -73,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -134,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -150,6 +165,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 1df5b9217e5..d84a2551202 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,25 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,27 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..e362c241e8c 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,7 +5,7 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; +const char Constants::TAG[] PROGMEM = "midea"; const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; const char *const Constants::SILENT = "Silent"; const char *const Constants::TURBO = "Turbo"; diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..62e93d2465a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,7 +17,7 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; + static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG static const char *const FREEZE_PROTECTION; static const char *const SILENT; static const char *const TURBO; diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4243d3116cf..4df22354681 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -18,7 +18,7 @@ namespace esphome::mipi_dsi { -constexpr static const char *const TAG = "display.mipi_dsi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 8b29e273abd..01b1ebee91f 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" +#include "esphome/core/log.h" #include "esphome/components/display/display.h" #include #ifdef USE_SPI @@ -10,7 +11,7 @@ namespace esphome::mipi_rgb { -constexpr static const char *const TAG = "display.mipi_rgb"; +ESPHOME_LOG_TAG(TAG, "display.mipi_rgb"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48a2cafbdd3..96f26ef19b7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,10 +7,11 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome::mipi_spi { -constexpr static const char *const TAG = "display.mipi_spi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_spi"); // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..89835128fe2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -100,6 +100,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..7b0d5ac1732 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::pca9505 diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index abf64f675af..a6dee79abb0 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -6,4 +6,5 @@ CONF_DRAW_FROM_ORIGIN = "draw_from_origin" DEPRECATED_COMPONENT = """ The 'qspi_dbi' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index dce1a95687d..cf2a6441a6c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -165,8 +165,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'qspi_dbi' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 8a1bf0d4c24..eca062f4711 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -8,12 +8,13 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_rgb.h" namespace esphome::qspi_dbi { -constexpr static const char *const TAG = "display.qspi_dbi"; +ESPHOME_LOG_TAG(TAG, "display.qspi_dbi"); static const uint8_t SW_RESET_CMD = 0x01; static const uint8_t SLEEP_OUT = 0x11; static const uint8_t NORON = 0x13; diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 20629fd7305..6b274b9b734 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_rgb' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 1ca29a32597..be6ca6c2a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -132,8 +132,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + "The 'rpi_dpi_rgb' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_rgb' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index df0e2b0b167..ad2f6e80c26 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -16,7 +16,7 @@ namespace esphome::rpi_dpi_rgb { -constexpr static const char *const TAG = "rpi_dpi_rgb"; +ESPHOME_LOG_TAG(TAG, "rpi_dpi_rgb"); class RpiDpiRgb final : public display::Display { public: diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 47b8f26e3ff..8bff8bb58dc 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] DOMAIN = "rtttl" CONF_RTTTL = "rtttl" +# The player keeps the song length in 16 bits +MAX_SONG_LENGTH = 65535 CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") @@ -97,16 +99,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +def _static_song(config: ConfigType, value: str) -> str: + # A constant song plays straight from static storage, flash on ESP8266, with no copy per play + return f"esphome::rtttl::StaticSong{{{cg.FlashStringLiteral(value)}}}" + + automation.register_apply_action( "rtttl.play", cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), - cv.Required(CONF_RTTTL): cv.templatable(cv.string), + cv.Required(CONF_RTTTL): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_SONG_LENGTH)) + ), }, key=CONF_RTTTL, ), - automation.ApplyField(CONF_RTTTL, "play", cg.std_string), + automation.ApplyField(CONF_RTTTL, "play", cg.std_string, const_fn=_static_song), ) automation.register_apply_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index fb2bf63a646..a1d49c65c72 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,5 +1,8 @@ #include "rtttl.h" +#include #include +#include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -147,14 +150,14 @@ void Rtttl::loop() { #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->position_ < this->rtttl_.length()) { - char c = this->rtttl_[this->position_]; + while (this->position_ < this->song_len_) { + char c = this->song_at_(this->position_); if (c != ',' && c != ' ') break; this->position_++; } - if (this->position_ >= this->rtttl_.length()) { + if (this->position_ >= this->song_len_) { this->finish_(); return; } @@ -169,12 +172,12 @@ void Rtttl::loop() { this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } - uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); + uint8_t note_index_in_octave = note_index_from_char(this->song_at_(this->position_)); this->position_++; // Now, get optional '#' sharp - if (this->rtttl_[this->position_] == '#') { + if (this->song_at_(this->position_) == '#') { note_index_in_octave++; this->position_++; } @@ -192,7 +195,7 @@ void Rtttl::loop() { } // Now, get optional '.' dotted note - if (this->rtttl_[this->position_] == '.') { + if (this->song_at_(this->position_) == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } @@ -264,15 +267,68 @@ void Rtttl::loop() { } void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + if (this->is_busy_()) { return; } + this->owned_song_ = std::move(rtttl); + this->set_song_(this->owned_song_.data(), this->owned_song_.size()); + this->start_(); +} - this->rtttl_ = std::move(rtttl); +void Rtttl::play(StaticSong song) { + if (this->is_busy_()) { + return; + } + // Release a previously owned song; the static one needs no storage. + std::string().swap(this->owned_song_); + const char *data = reinterpret_cast(song.rtttl); + this->set_song_(data, ESPHOME_strlen_P(data)); + this->start_(); +} +void Rtttl::set_song_(const char *song, size_t len) { + this->song_ = song; + // Anything past what the 16 bit length holds is dropped; the validator keeps configured songs shorter. + this->song_len_ = static_cast(std::min(len, std::numeric_limits::max())); +} + +bool Rtttl::is_busy_() const { + if (this->state_ == State::STOPPED || this->state_ == State::STOPPING) { + return false; + } + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->song_find_(':', 0, this->song_len_)); + ESP_LOGW(TAG, "Already playing: %s", name); + return true; +} + +size_t Rtttl::song_find_(char c, size_t from, size_t end) const { + for (size_t pos = from; pos < end; pos++) { + if (this->song_at_(pos) == c) + return pos; + } + return end; +} + +size_t Rtttl::song_find_control_(char key, size_t from, size_t end) const { + size_t pos = from; + while ((pos = this->song_find_(key, pos, end)) < end) { + if (this->song_at_(pos + 1) == '=') + return pos; + pos++; + } + return end; +} + +void Rtttl::song_name_(char *buf, size_t size, size_t name_len) const { + size_t len = std::min(name_len, size - 1); + for (size_t pos = 0; pos < len; pos++) { + buf[pos] = this->song_at_(pos); + } + buf[len] = '\0'; +} + +void Rtttl::start_() { this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; @@ -281,9 +337,9 @@ void Rtttl::play(std::string rtttl) { uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name - this->position_ = this->rtttl_.find(':'); + this->position_ = this->song_find_(':', 0, this->song_len_); - if (this->position_ == std::string::npos) { + if (this->position_ == this->song_len_) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } @@ -292,18 +348,20 @@ void Rtttl::play(std::string rtttl) { static_cast(SONG_NAME_LENGTH_LIMIT)); return; } - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->position_); + ESP_LOGD(TAG, "Playing song %s", name); size_t name_end_position = this->position_; - size_t control_end = this->rtttl_.find(':', name_end_position + 1); - if (control_end == std::string::npos) { + size_t control_end = this->song_find_(':', name_end_position + 1, this->song_len_); + if (control_end == this->song_len_) { ESP_LOGE(TAG, "Missing second ':'"); return; } // Get default duration - size_t pos = this->rtttl_.find("d=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + size_t pos = this->song_find_control_('d', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); } else { this->position_ = pos + 2; @@ -317,8 +375,8 @@ void Rtttl::play(std::string rtttl) { } // Get default octave - pos = this->rtttl_.find("o=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('o', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); } else { this->position_ = pos + 2; @@ -332,8 +390,8 @@ void Rtttl::play(std::string rtttl) { } // Get BPM - pos = this->rtttl_.find("b=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('b', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); } else { this->position_ = pos + 2; @@ -387,7 +445,7 @@ void Rtttl::stop() { } #endif // USE_SPEAKER - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } @@ -411,7 +469,7 @@ void Rtttl::finish_() { #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 055204571e1..ade2dcd3205 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -14,6 +15,10 @@ namespace esphome::rtttl { +namespace testing { +class RtttlParserAccess; +} // namespace testing + inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note) inline constexpr uint8_t DEFAULT_OCTAVE = 6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`) @@ -26,6 +31,11 @@ enum class State : uint8_t { STOPPING, }; +/// A song in storage that outlives playback, such as a literal from generated code; PROGMEM on ESP8266. +struct StaticSong { + ProgmemStr rtttl; +}; + class Rtttl final : public Component { public: #ifdef USE_OUTPUT @@ -38,7 +48,10 @@ class Rtttl final : public Component { void dump_config() override; void loop() override; + /// Play a song; the string is copied so it may come from anywhere. void play(std::string rtttl); + /// Play a song from static storage without copying it. + void play(StaticSong song); void stop(); float get_gain() { return this->gain_; } @@ -55,11 +68,29 @@ class Rtttl final : public Component { protected: inline uint16_t get_integer_() { uint16_t ret = 0; - while (isdigit(this->rtttl_[this->position_])) { - ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); + while (isdigit(this->song_at_(this->position_))) { + ret = (ret * 10) + (this->song_at_(this->position_++) - '0'); } return ret; } + /// The byte of the song at pos, or NUL past its end; the read is flash safe for a PROGMEM song on ESP8266. + char song_at_(size_t pos) const { + if (pos >= this->song_len_) + return '\0'; + return static_cast(progmem_read_byte(reinterpret_cast(this->song_) + pos)); + } + /// Index of the first `c` in [from, end), or `end` when absent. + size_t song_find_(char c, size_t from, size_t end) const; + /// Index of `key` followed by '=' in [from, end), or `end` when absent. + size_t song_find_control_(char key, size_t from, size_t end) const; + /// Copies the first name_len bytes of the song, its name, into buf for logging. + void song_name_(char *buf, size_t size, size_t name_len) const; + /// Points the view at a song, clamped to what song_len_ can hold. + void set_song_(const char *song, size_t len); + /// Parses the header and starts playback of the song the view points at. + void start_(); + /// Logs and returns true when a song is still playing, so a new one must wait. + bool is_busy_() const; /** * @brief Finalizes the playback of the RTTTL string. * @@ -70,26 +101,30 @@ class Rtttl final : public Component { void finish_(); void set_state_(State state); - /// The RTTTL string to play. - std::string rtttl_; - /// The current position in the RTTTL string. - size_t position_{0}; - /// The default duration of a note (e.g. 4 for a quarter note). - uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; - /// The default octave for a note. - uint8_t default_octave_{DEFAULT_OCTAVE}; - /// The duration of the current note in milliseconds. - uint16_t note_duration_{0}; - /// The duration of a whole note in milliseconds. - uint16_t wholenote_duration_; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; + /// The song being played; points at owned_song_ or at static storage. + const char *song_{nullptr}; + /// Backing store for play(std::string). + std::string owned_song_; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; + /// Length of the song in bytes; songs are a few hundred bytes, so 16 bits is ample. + uint16_t song_len_{0}; + /// The current position in the song. + uint16_t position_{0}; + /// The duration of the current note in milliseconds. + uint16_t note_duration_{0}; + /// The duration of a whole note in milliseconds. + uint16_t wholenote_duration_; + /// The default duration of a note (e.g. 4 for a quarter note). + uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; + /// The default octave for a note. + uint8_t default_octave_{DEFAULT_OCTAVE}; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -113,6 +148,8 @@ class Rtttl final : public Component { /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; #endif + + friend class testing::RtttlParserAccess; }; } // namespace esphome::rtttl diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 2c783a0df30..61b152d7d36 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -15,7 +15,7 @@ class Component; // Forward declaration namespace runtime_stats { -static const char *const TAG = "runtime_stats"; +ESPHOME_LOG_TAG(TAG, "runtime_stats"); class RuntimeStatsCollector final { public: diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 54f0d2573fd..c58de643cc8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { -constexpr static const char *const TAG = "sdl"; +ESPHOME_LOG_TAG(TAG, "sdl"); class Sdl final : public display::Display, public snapshot::Snapshot { public: diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 20b9c25c2e9..a5b10a0e9a6 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -7,7 +7,7 @@ namespace esphome::spi_led_strip { -static const char *const TAG = "spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); class SpiLedStrip final : public light::AddressableLight, public spi::SPIDevice { diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index d44f8c68590..fe29d794141 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -8,13 +8,14 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_rgb.h" namespace esphome::st7701s { -constexpr static const char *const TAG = "display.st7701s"; +ESPHOME_LOG_TAG(TAG, "display.st7701s"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 176eb233e17..d6e5035a9a4 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -7,4 +7,5 @@ st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ The 'st7735' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 5cbcee12ab3..d6774f4639e 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -92,8 +92,10 @@ async def setup_st7735(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7735' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index e67f4809128..dfe139d0183 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -7,4 +7,5 @@ st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ The 'st7789v' component is deprecated and no new functionality will be added to it. PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index fa72c7c328a..82d3f0b26d6 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -183,8 +183,10 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7789v' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..19e4813b70d 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -59,7 +61,12 @@ static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +136,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { diff --git a/esphome/components/tmp102/text_sensor.py b/esphome/components/tmp102/text_sensor.py new file mode 100644 index 00000000000..5d901bfab89 --- /dev/null +++ b/esphome/components/tmp102/text_sensor.py @@ -0,0 +1,31 @@ +"""TMP102 threshold status entity.""" + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC +).extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "text_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_TEXT_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(parent.set_threshold_status_text_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index bb82975dbc3..06d94f1ba86 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -1,8 +1,14 @@ #include "tmp102.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/core/progmem.h" +#endif #include +#ifdef USE_TMP102_TEXT_SENSOR +#include +#endif namespace esphome::tmp102 { @@ -12,6 +18,30 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; + +enum TMP102ThresholdStatus : uint8_t { + TMP102_THRESHOLD_STATUS_NOT_CONFIGURED, + TMP102_THRESHOLD_STATUS_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_OK, + TMP102_THRESHOLD_STATUS_RESTORE_RESET, + TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERY_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERED, + TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED, + TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED, + TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_COUNT, +}; + +#ifdef USE_TMP102_TEXT_SENSOR +PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset", + "Restore reset; save failed", "Preference save failed", "Configuration recovery failed", + "Configuration restored", "Write failed: high threshold", "Write failed: low threshold", + "Rejected: sensor setup failed"); +static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT); +#endif + #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; @@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) { return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); } #endif +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void TMP102Component::setup() { - if (!this->configure_) +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->setup_configured_(); return; + } +#endif + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED); +} +#endif + +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::setup_configured_() { + bool corrected = false; #ifdef USE_TMP102_NUMBER float restored[2]{}; - this->prepare_number_limits_(restored); + bool restore_corrected[2]{}; + corrected = this->prepare_number_limits_(restored, restore_corrected); #endif if (!this->write_configuration_()) { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); this->mark_failed(); return; } @@ -61,14 +106,17 @@ void TMP102Component::setup() { if (control == nullptr) continue; const float value = this->get_limit_temperature_(static_cast(limit)); - if (value != restored[limit]) + if (restore_corrected[limit] || value != restored[limit]) saved &= control->save_value_(value); control->publish_state(value); } if (!saved) { ESP_LOGW(TAG, "Failed to save threshold preferences"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED); + return; } #endif + this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK); } #endif @@ -83,6 +131,9 @@ void TMP102Component::dump_config() { #ifdef USE_TMP102_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); #endif +#ifdef USE_TMP102_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() { this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED); + } else { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED); } return false; } @@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) { this->limit_controls_[number->limit_type_] = number; } -void TMP102Component::prepare_number_limits_(float *restored) { +bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) { const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + bool corrected = false; for (uint8_t limit = 0; limit < 2; ++limit) { TMP102LimitNumber *control = this->limit_controls_[limit]; if (control == nullptr) continue; - restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]); + corrected |= restore_corrected[limit]; raw[limit] = encode_temperature(restored[limit], extended); } if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); - return; + return true; } this->low_limit_ = raw[TMP102_LIMIT_LOW]; this->high_limit_ = raw[TMP102_LIMIT_HIGH]; + return corrected; } float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { @@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended)); return false; } if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { this->status_set_warning(); + this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED + : TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED); return false; } (high ? this->high_limit_ : this->low_limit_) = raw; @@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper #endif #endif +#ifdef USE_TMP102_TEXT_SENSOR +void TMP102Component::publish_threshold_status_(uint8_t status) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[40]; + ESPHOME_strncpy_P( + text, + reinterpret_cast(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->threshold_status_text_sensor_->publish_state(text); +} + +void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[96]; + if (high) { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other); + } else { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other); + } + this->threshold_status_text_sensor_->publish_state(text); +} +#endif + #ifdef USE_TMP102_BINARY_SENSOR void TMP102Component::invalidate_alert_() { if (this->alert_binary_sensor_ != nullptr) @@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() { #endif #ifdef USE_TMP102_NUMBER -float TMP102LimitNumber::load_value_(float fallback) { +float TMP102LimitNumber::load_value_(float fallback, bool &corrected) { float value = fallback; if (this->restore_value_) { this->pref_ = this->make_entity_preference(); @@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) { if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); value = fallback; + corrected = true; } return value; } @@ -313,6 +400,7 @@ void TMP102LimitNumber::control(float value) { // A chip that failed setup holds nothing the entity could show if (this->parent_->is_failed()) { ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED); return; } const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); @@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) { this->publish_state(value); if (accepted && !this->save_value_(value)) { ESP_LOGW(TAG, "Failed to save threshold preference"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED); + } else if (accepted) { + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK); } } #endif diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index d88dde78344..34cdfc973c1 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -12,11 +12,20 @@ #include "esphome/core/preferences.h" #endif #include "esphome/components/sensor/sensor.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include namespace esphome::tmp102 { +#ifdef USE_HOST +namespace testing { +class TMP102Test; +} +#endif + #ifdef USE_TMP102_NUMBER enum TMP102LimitType : uint8_t { TMP102_LIMIT_LOW = 0, @@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #ifdef USE_TMP102_BINARY_SENSOR SUB_BINARY_SENSOR(alert) #endif -#ifdef USE_TMP102_CONFIGURE +#ifdef USE_TMP102_TEXT_SENSOR + SUB_TEXT_SENSOR(threshold_status) +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void setup() override; +#endif +#ifdef USE_TMP102_CONFIGURE void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { this->config_ = config; this->high_limit_ = high_limit; @@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: +#ifdef USE_TMP102_TEXT_SENSOR + void publish_threshold_status_(uint8_t status); + void publish_threshold_rejection_(bool high, float value, float other); +#else + void publish_threshold_status_(uint8_t) {} + void publish_threshold_rejection_(bool, float, float) {} +#endif void set_warning_and_invalidate_alert_() { this->status_set_warning(); #ifdef USE_TMP102_BINARY_SENSOR @@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void invalidate_alert_(); #endif #ifdef USE_TMP102_CONFIGURE + void setup_configured_(); bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); bool check_configuration_(); @@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu friend class TMP102LimitNumber; bool set_limit_temperature_(TMP102LimitType limit, float temperature); float get_limit_temperature_(TMP102LimitType limit) const; - void prepare_number_limits_(float *restored); + bool prepare_number_limits_(float *restored, bool *restore_corrected); TMP102LimitNumber *limit_controls_[2]{}; #endif - uint16_t config_{0}; uint16_t high_limit_{0}; uint16_t low_limit_{0}; @@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented - namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { @@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72; const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E; // RAC-PT1411HWRU Swing const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9; -const std::vector RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; -const std::vector RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; // RAC-PT1411HWRU Fan speeds const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B; constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66}; @@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E; // Hence these look-up tables. In addition, the upper nibble is used here for additional // "negative" and "fractional value" flags as required for some temperatures. // RAC-PT1411HWRU °C Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, - 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, + 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; // RAC-PT1411HWRU °F Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, - 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, - 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = { + 0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05, + 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; // RAS-2819T protocol constants const uint16_t RAS_2819T_HEADER1 = 0xC23D; @@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame // RAS-2819T temperature codes for 18-30°C -static const uint8_t RAS_2819T_TEMP_CODES[] = { +static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = { 0x10, // 18°C 0x30, // 19°C 0x20, // 20°C @@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) { return 0x40; // Default to 24°C } - return RAS_2819T_TEMP_CODES[temp_index]; + return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]); } /** @@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) { // Find the code in the temperature array for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { - if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) { return static_cast(temp_index + 18); // 18°C is the minimum } } @@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { index = static_cast(roundf(temp_adjd)); if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - code = RAC_PT1411HWRU_TEMPERATURE_F[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]); message[9] |= RAC_PT1411HWRU_FLAG_FAH; } else { - code = RAC_PT1411HWRU_TEMPERATURE_C[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]); } if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) { code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY; @@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { // Swing Mode data->reset(); data->space(TOSHIBA_PACKET_SPACE); - switch (this->swing_mode) { - case climate::CLIMATE_SWING_VERTICAL: - this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - break; - - case climate::CLIMATE_SWING_OFF: - default: - this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - } + const uint8_t *swing_frame = + this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF; + uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH]; + progmem_memcpy(swing, swing_frame, sizeof(swing)); + this->encode_(data, swing, sizeof(swing), 1); data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); @@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) { this->target_temperature = static_cast((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9; } } } else { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) { this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; } } @@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Swing mode case RAC_PT1411HWRU_SWING_HEADER: - if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) { + if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; } else { this->swing_mode = climate::CLIMATE_SWING_OFF; diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index aaab8e22a18..bbb6f15f292 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,6 +12,7 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -39,7 +40,7 @@ namespace esphome::usb_host { // - Main loop controls backpressure by deciding when to restart after consuming data // The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking. -static const char *const TAG = "usb_host"; +ESPHOME_LOG_TAG(TAG, "usb_host"); // Forward declarations struct TransferRequest; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..b54641884e6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -9,6 +9,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -19,7 +20,7 @@ class USBUartComponent; class USBUartChannelBase; class USBUartTypePL2303; -static const char *const TAG = "usb_uart"; +ESPHOME_LOG_TAG(TAG, "usb_uart"); static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 1dbce873073..12ea8e8ade0 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" -static const char *const TAG = "Zio Ultrasonic"; +ESPHOME_LOG_TAG(TAG, "Zio Ultrasonic"); namespace esphome::zio_ultrasonic { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..234160fc290 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -69,6 +69,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT @@ -216,6 +217,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -270,6 +272,7 @@ #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TMP102_NUMBER +#define USE_TMP102_TEXT_SENSOR #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX @@ -566,6 +569,10 @@ #endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -646,6 +653,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 906f9fb12ab..872bb199084 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef USE_ESP32 @@ -315,6 +316,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size // Parsing & formatting +// A plain decimal converts exactly with one division: the digits accumulate +// into an integer that stays below 2^24 and the fraction's scale is a power of +// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division +// rounds the quotient correctly, the same result strtof produces. +static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24 +static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9; + +optional parse_float(const char *str) { + const char *p = str; + const bool negative = *p == '-'; + if (negative || *p == '+') + p++; + const char *digits = p; + const char *dot = nullptr; + uint32_t mantissa = 0; + for (;; p++) { + const uint32_t d = static_cast(*p) - '0'; + if (d > 9) { + if (*p == '.' && dot == nullptr) { + dot = p; + continue; + } + break; + } + if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT) + break; + mantissa = mantissa * 10 + d; + } + const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0; + const bool any_digit = p - digits > (dot != nullptr ? 1 : 0); + if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) { + // The mantissa is exact in a float, so the sign goes on before the one rounding step + float value = static_cast(mantissa); + if (negative) + value = -value; + if (fraction_digits != 0) { + uint32_t scale = 10; + for (uint32_t i = 1; i < fraction_digits; i++) + scale *= 10; + value /= static_cast(scale); + } + return value; + } + // Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path. + char *end = nullptr; + const float value = ::strtof(str, &end); + if (end == str || *end != '\0' || value == HUGE_VALF) + return {}; + return value; +} + size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bcb03b010f6..15f247cadbc 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1299,13 +1299,11 @@ template::value && std::is_signed parse_number(const std::string &str) { return parse_number(str.c_str()); } +/// Parse a decimal floating-point number; plain decimals skip the libc parser. +optional parse_float(const char *str); /// Parse a decimal floating-point number from a null-terminated string. template::value), int> = 0> optional parse_number(const char *str) { - char *end = nullptr; - float value = ::strtof(str, &end); - if (end == str || *end != '\0' || value == HUGE_VALF) - return {}; - return value; + return parse_float(str); } /// Parse a decimal floating-point number. template::value), int> = 0> diff --git a/esphome/core/log.h b/esphome/core/log.h index 145ba3a6f8a..840da292aee 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,8 +190,9 @@ struct LogString; #endif -// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with -// the _P functions, never strcmp/strlen. +// Declares a static log tag at namespace scope, in a source file or a header; on ESP8266 it lives in flash, so +// read it with the _P functions, never strcmp/strlen. Not usable for a class member: the ESP8266 form is a +// PROGMEM array, which cannot be initialized in a class. #ifdef USE_ESP8266 #define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag #else diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9fc1c717032..e2353db28f4 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -22,7 +22,7 @@ namespace esphome { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -static const char *const TAG = "wake"; +ESPHOME_LOG_TAG(TAG, "wake"); namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..2ef48602d1b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..96f3d25b6eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY diff --git a/requirements.txt b/requirements.txt index a9dbaa3b8d7..5397959f63f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 @@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..65e8a63bfe9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py diff --git a/script/ci-custom.py b/script/ci-custom.py index 8361387af01..5b268d62969 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1269,11 +1269,15 @@ def lint_no_std_nothrow(fname, match): ) +# Every C++ source and header; plain C files cannot use the macro +LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] + + @lint_re_check( - r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" - r"\s*(?:=\s*\{?|[{(])\s*\"", + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?(?:\w+::)*TAG|\s(?:const\s+)?(?:\w+::)*TAG\s*\[\w*\])" + r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_macro(fname, match): return ( @@ -1281,6 +1285,8 @@ def lint_log_tag_macro(fname, match): f"ESP8266.\n" f" Before: {highlight('static const char *const TAG = "name";')}\n" f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"A class member tag cannot use the macro; declare {highlight('static const char TAG[];')} in the class " + f"and define {highlight('const char Foo::TAG[] PROGMEM = "name";')} in the .cpp.\n" f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @@ -1288,11 +1294,11 @@ def lint_log_tag_macro(fname, match): @lint_re_check( r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" - r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" - r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*(?:\w+::)*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*(?:\w+::)*TAG\b", mask=True, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_as_string(fname, match): return ( diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/component_tests/lvgl/config/image.png b/tests/component_tests/lvgl/config/image.png new file mode 100644 index 00000000000..dec8dd15317 Binary files /dev/null and b/tests/component_tests/lvgl/config/image.png differ diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml index 44407348999..58898c89b12 100644 --- a/tests/component_tests/lvgl/config/list_outside_block_test.yaml +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -30,7 +30,7 @@ display: image: - platform: file - file: mdi:battery + file: image.png id: my_image resize: 8x8 type: binary diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) diff --git a/tests/component_tests/tmp102/config/duplicate_status.yaml b/tests/component_tests/tmp102/config/duplicate_status.yaml new file mode 100644 index 00000000000..d67c61be41b --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_status.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: parent_sensor + name: Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: parent_sensor + name: First Status + - platform: tmp102 + tmp102_id: parent_sensor + name: Second Status diff --git a/tests/component_tests/tmp102/config/status.yaml b/tests/component_tests/tmp102/config/status.yaml new file mode 100644 index 00000000000..e2f2318da4f --- /dev/null +++ b/tests/component_tests/tmp102/config/status.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: parent_sensor + name: Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: parent_sensor + name: Threshold Status diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index b933db1d9bc..036dee4b740 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,30 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_status_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("status.yaml")) + assert "set_threshold_status_text_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_TEXT_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_status_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_status.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 text_sensor may reference each temperature sensor" + in capsys.readouterr().out + ) + + def test_threshold_numbers_enable_configuration_once( generate_main: Callable[[Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/core/test_parse_float.cpp b/tests/components/core/test_parse_float.cpp new file mode 100644 index 00000000000..1d52f8afc8f --- /dev/null +++ b/tests/components/core/test_parse_float.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static uint32_t bits(float value) { + uint32_t out; + memcpy(&out, &value, sizeof(out)); + return out; +} + +// strtof is the reference: every accepted input must give the same bits. +static void expect_matches_strtof(const char *str) { + char *end = nullptr; + const float expected = ::strtof(str, &end); + const auto parsed = parse_number(str); + if (!parsed.has_value()) { + ADD_FAILURE() << str << " was rejected"; + return; + } + const float actual = parsed.value(); + EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected; +} + +TEST(ParseFloat, PlainDecimalsMatchStrtof) { + for (const char *str : + {"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3", + "2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5", + "-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) { + expect_matches_strtof(str); + } +} + +TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) { + char buf[16]; + for (int i = -20000; i <= 20000; i++) { + snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100); + expect_matches_strtof(buf); + } +} + +// A fraction longer than the fast path accepts must fall back, however long it is. +TEST(ParseFloat, LongFractionsMatchStrtof) { + std::string str = "0."; + str.append(300, '0'); + str += "25"; + expect_matches_strtof(str.c_str()); + expect_matches_strtof(("-" + str).c_str()); + str = "1."; + str.append(300, '0'); + expect_matches_strtof(str.c_str()); +} + +TEST(ParseFloat, LibcPathInputsMatchStrtof) { + for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001", + "0.00000000001", "1.23456789", "3.4028235e38"}) { + expect_matches_strtof(str); + } +} + +// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser +// rejects both; the negative forms pass through, as they did before the fast path. +TEST(ParseFloat, RejectsInvalidInput) { + for (const char *str : + {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) { + EXPECT_FALSE(parse_number(str).has_value()) << str; + } +} + +} // namespace esphome::core::testing diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); 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); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true); diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 3839b4ff87a..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + 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 + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + 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 + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi @@ -234,6 +268,27 @@ 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)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.66in-b + 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 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + 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 diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp new file mode 100644 index 00000000000..3c706f29877 --- /dev/null +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include "esphome/components/rtttl/rtttl.h" + +namespace esphome::rtttl::testing { + +// Reads the parser state the tests check; Rtttl is final, so this is a friend rather than a subclass. +class RtttlParserAccess { + public: + static uint8_t denominator(const Rtttl &p) { return p.default_note_denominator_; } + static uint8_t octave(const Rtttl &p) { return p.default_octave_; } + static uint16_t wholenote(const Rtttl &p) { return p.wholenote_duration_; } + static uint16_t note_duration(const Rtttl &p) { return p.note_duration_; } + static uint32_t freq(const Rtttl &p) { return p.output_freq_; } + static size_t position(const Rtttl &p) { return p.position_; } + static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } + static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } +}; + +using Access = RtttlParserAccess; + +static const char SONG[] = "test:d=8,o=5,b=100:c"; + +TEST(RtttlParser, StaticSongParsesTheHeader) { + Rtttl player; + player.play(StaticSong{SONG}); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 100); + // Positioned on the first note after the second ':' + EXPECT_EQ(Access::position(player), 19u); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); +} + +TEST(RtttlParser, OwnedSongParsesTheSameAndOutlivesTheCaller) { + Rtttl player; + { + std::string song(SONG); + player.play(song); + } + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::at(player, 0), 't'); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); + EXPECT_EQ(Access::at(player, 100), '\0'); +} + +TEST(RtttlParser, ControlOrderDoesNotMatter) { + for (const char *song : {"a:b=100,o=5,d=8:c", "a:o=5,b=100,d=8:c", "a:d=8,b=100,o=5:c"}) { + Rtttl player; + player.play(StaticSong{song}); + EXPECT_EQ(Access::denominator(player), 8) << song; + EXPECT_EQ(Access::octave(player), 5) << song; + EXPECT_EQ(Access::wholenote(player), 2400) << song; + } +} + +TEST(RtttlParser, MissingControlsUseDefaults) { + Rtttl player; + player.play(StaticSong{"a::c"}); + EXPECT_EQ(Access::denominator(player), DEFAULT_NOTE_DENOMINATOR); + EXPECT_EQ(Access::octave(player), DEFAULT_OCTAVE); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 63); +} + +TEST(RtttlParser, NotesReadFromStaticStorage) { + Rtttl player; + player.play(StaticSong{"t:d=4,o=5,b=120:8c,p"}); + Access::set_running(player); + player.loop(); + // C5 at 523 Hz for an eighth of a 2000 ms whole note + EXPECT_EQ(Access::freq(player), 523u); + EXPECT_EQ(Access::note_duration(player), 250); + player.loop(); + // A pause for a default quarter note + EXPECT_EQ(Access::freq(player), 0u); + EXPECT_EQ(Access::note_duration(player), 500); + player.loop(); + // End of the song + EXPECT_EQ(Access::at(player, Access::position(player)), '\0'); +} + +TEST(RtttlParser, RefusesANewSongWhilePlaying) { + Rtttl player; + player.play(StaticSong{"first:d=8:c"}); + Access::set_running(player); + player.play(std::string("second:d=16:c")); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::at(player, 0), 'f'); +} + +TEST(RtttlParser, ClampsASongLongerThanTheLengthField) { + Rtttl player; + player.play(std::string(70000, 'a')); + EXPECT_EQ(Access::at(player, 65534), 'a'); + EXPECT_EQ(Access::at(player, 65535), '\0'); + EXPECT_EQ(Access::note_duration(player), 0); +} + +TEST(RtttlParser, RejectsASongWithoutAName) { + Rtttl player; + player.play(StaticSong{"no colon here"}); + EXPECT_EQ(Access::note_duration(player), 0); + EXPECT_EQ(Access::position(player), 13u); +} + +} // namespace esphome::rtttl::testing diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..c39d3fd8ba2 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -84,3 +84,26 @@ esphome: target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 346efac83ec..60de2d34958 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,11 +6,12 @@ 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", "number"): + for platform in ("sensor", "binary_sensor", "number", "text_sensor"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") cg.add_define("USE_TMP102_NUMBER") + cg.add_define("USE_TMP102_TEXT_SENSOR") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + [ @@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "sensor", "binary_sensor", "number", + "text_sensor", ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index dfc2e2e2245..3d1f66da5bb 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -30,3 +30,8 @@ binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature name: TMP102 Alert + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml new file mode 100644 index 00000000000..21880a6338f --- /dev/null +++ b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: tmp102 + id: tmp102_text_sensor_parent + name: TMP102 Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_text_sensor_parent + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 6c294f4cdc6..a5b65b060d4 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" @@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus { unsigned transactions{0}; unsigned writes{0}; uint8_t pointer{0}; + std::function on_write; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { @@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_NOT_ACKNOWLEDGED; ++this->writes; this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + if (this->on_write) { + auto callback = std::move(this->on_write); + callback(); + } } } if (out_size != 0) { @@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + text_sensor::TextSensor status_; TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; @@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test { static void save(TMP102LimitNumber &number, float value) { ASSERT_TRUE(number.make_entity_preference().save(&value)); } + + static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { EXPECT_EQ(this->bus_.registers, original); } +TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + EXPECT_EQ(this->bus_.registers, original); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "Not configured"); +} + +TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "OK"); +} + TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { for (bool configured_extended : {false, true}) { this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, @@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 20); save(this->low_, 35); @@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { EXPECT_FLOAT_EQ(this->low_.state, 25); EXPECT_FLOAT_EQ(saved(this->high_), 30); EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); +} + +TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->bus_.on_write = [this]() { + fail_saves(this->high_); + fail_saves(this->low_); + }; + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->status_.state, "Restore reset; save failed"); } TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, NAN); save(this->low_, INFINITY); this->chip_.setup(); EXPECT_FLOAT_EQ(this->high_.state, 30); EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); } TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); this->bus_.fail_write = 3; this->chip_.setup(); EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_EQ(this->status_.state, "Setup failed"); EXPECT_FALSE(this->high_.has_state()); EXPECT_FLOAT_EQ(saved(this->high_), 90); EXPECT_FLOAT_EQ(saved(this->low_), 85); } TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); this->bus_.fail_write = 3; @@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { EXPECT_FALSE(this->high_.has_state()); EXPECT_EQ(this->bus_.writes, writes); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed"); } TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { @@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { } TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); @@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { EXPECT_FLOAT_EQ(this->high_.state, 90); EXPECT_EQ(publications, 1); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Write failed: high threshold"); + + this->bus_.fail_write = 2; + this->low_.make_call().set_value(80).perform(); + EXPECT_FLOAT_EQ(this->low_.state, 85); + EXPECT_FLOAT_EQ(saved(this->low_), 85); + EXPECT_EQ(this->status_.state, "Write failed: low threshold"); +} + +TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + fail_saves(this->high_); + this->high_.make_call().set_value(35).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_EQ(this->status_.state, "Preference save failed"); +} + +TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + this->high_.make_call().set_value(20).perform(); + EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C"); + this->high_.make_call().set_value(35).perform(); + EXPECT_EQ(this->status_.state, "OK"); +} + +TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + this->bus_.registers[1] = 0; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration restored"); + + this->bus_.registers[1] = 0; + this->bus_.fail_write = 1; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration recovery failed"); } TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 528af888544..3c0ce6cd6ce 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); } +// The RAC-PT1411HWRU sends its main message, then a separate swing packet +// wrapped in packet spaces; both use the generic timings. +static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector &bytes) { + ASSERT_GE(actual.size(), 2u); + EXPECT_EQ(actual.front(), -10500); + EXPECT_EQ(actual.back(), -10500); + const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1); + expect_frame(frame, waveform(bytes, GENERIC), 6); +} + +TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) { + Fixture f(MODEL_RAC_PT1411HWRU_C); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}); +} + +// The temperature tables are read on the way out and searched on the way back +// in, so what one fixture sends must decode to the same state in another. +TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { + for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) { + for (int temperature = 16; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature); + Fixture tx(model); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + // The main message is sent before the swing packet + const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; + + Fixture rx(model); + remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); + } + } +} + +// The RAS-2819T table is read for the code on the way out and searched on the way back in. +TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { + for (int temperature = 18; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "temperature " << temperature); + Fixture tx(MODEL_RAS_2819T); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + + Fixture rx(MODEL_RAS_2819T); + remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); + } +} + static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { const remote_base::RawTimings raw = waveform(bytes, t); remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index f303d19bb0d..e65bf9755e3 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -400,6 +400,9 @@ def test_ternary_error_message_names_the_literal() -> None: 'static constexpr char TAG[] = {"x"};', 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', + 'constexpr static const char *const TAG = "x";', + 'const char *const Constants::TAG = "x";', + 'const char Constants::TAG[] = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -408,12 +411,38 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: @pytest.mark.parametrize( "line", - ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], + [ + 'ESPHOME_LOG_TAG(TAG, "x");', + 'static const char *const NAME = "x";', + 'static const char *const TAG = "x"; // NOLINT', + 'const char Constants::TAG[] PROGMEM = "x";', + ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("fname", "covered"), + [ + ("esphome/components/x/x.cpp", True), + ("esphome/components/x/x.h", True), + ("esphome/components/x/sub/x.h", True), + ("esphome/core/wake/wake_host.cpp", True), + ("esphome/components/x/x.c", False), + ], +) +def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None: + for name, line in ( + ("lint_log_tag_macro", 'static const char *const TAG = "x";\n'), + ("lint_log_tag_as_string", "strcmp(TAG, name);\n"), + ): + lint = next( + c for c in ci_custom.LINT_CONTENT_CHECKS if c["func"].__name__ == name + ) + assert bool(ci_custom.run_check(lint, fname, fname, line)) is covered + + @pytest.mark.parametrize( "line", [ @@ -426,6 +455,8 @@ def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: "std::string(TAG);", "std::string name = TAG;", "std::string name{TAG};", + "strlen(Constants::TAG);", + "this->set_timeout(Constants::TAG, 100, cb);", ], ) def test_log_tag_as_string_flags_string_uses(line: str) -> None: diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config)