diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1cb5f98c28..ab354259e3 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -cf3d341206b4184ec8b7fe85141aef4fe4696aa720c3f8a06d4e57930574bdab +069fa9526c52f7c580a9ec17c7678d12f142221387e9b561c18f95394d4629a3 diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index e178610a97..6d7d4f8c12 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: venv # yamllint disable-line rule:line-length diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index d5e42fa1b9..bd60d8c766 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -3,6 +3,7 @@ module.exports = { BOT_COMMENT_MARKER: '', CODEOWNERS_MARKER: '', TOO_BIG_MARKER: '', + DEPRECATED_COMPONENT_MARKER: '', MANAGED_LABELS: [ 'new-component', @@ -27,6 +28,7 @@ module.exports = { 'breaking-change', 'developer-breaking-change', 'code-quality', + 'deprecated-component' ], DOCS_PR_PATTERNS: [ diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 79025988dd..f502a85666 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -251,6 +251,76 @@ async function detectPRTemplateCheckboxes(context) { return labels; } +// Strategy: Deprecated component detection +async function detectDeprecatedComponents(github, context, changedFiles) { + const labels = new Set(); + const deprecatedInfo = []; + const { owner, repo } = context.repo; + + // Compile regex once for better performance + const componentFileRegex = /^esphome\/components\/([^\/]+)\//; + + // Get files that are modified or added in components directory + const componentFiles = changedFiles.filter(file => componentFileRegex.test(file)); + + if (componentFiles.length === 0) { + return { labels, deprecatedInfo }; + } + + // Extract unique component names using the same regex + const components = new Set(); + for (const file of componentFiles) { + const match = file.match(componentFileRegex); + if (match) { + components.add(match[1]); + } + } + + // Get PR head to fetch files from the PR branch + const prNumber = context.payload.pull_request.number; + + // Check each component's __init__.py for DEPRECATED_COMPONENT constant + for (const component of components) { + const initFile = `esphome/components/${component}/__init__.py`; + try { + // Fetch file content from PR head using GitHub API + const { data: fileData } = await github.rest.repos.getContent({ + owner, + repo, + path: initFile, + ref: `refs/pull/${prNumber}/head` + }); + + // Decode base64 content + const content = Buffer.from(fileData.content, 'base64').toString('utf8'); + + // Look for DEPRECATED_COMPONENT = "message" or DEPRECATED_COMPONENT = 'message' + // Support single quotes, double quotes, and triple quotes (for multiline) + const doubleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*"""([\s\S]*?)"""/s) || + content.match(/DEPRECATED_COMPONENT\s*=\s*"((?:[^"\\]|\\.)*)"/); + const singleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*'''([\s\S]*?)'''/s) || + content.match(/DEPRECATED_COMPONENT\s*=\s*'((?:[^'\\]|\\.)*)'/); + const deprecatedMatch = doubleQuoteMatch || singleQuoteMatch; + + if (deprecatedMatch) { + labels.add('deprecated-component'); + deprecatedInfo.push({ + component: component, + message: deprecatedMatch[1].trim() + }); + console.log(`Found deprecated component: ${component}`); + } + } catch (error) { + // Only log if it's not a simple "file not found" error (404) + if (error.status !== 404) { + console.log(`Error reading ${initFile}:`, error.message); + } + } + } + + return { labels, deprecatedInfo }; +} + // Strategy: Requirements detection async function detectRequirements(allLabels, prFiles, context) { const labels = new Set(); @@ -298,5 +368,6 @@ module.exports = { detectCodeOwner, detectTests, detectPRTemplateCheckboxes, + detectDeprecatedComponents, detectRequirements }; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 95ecfc4e33..483d2cb626 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -11,6 +11,7 @@ const { detectCodeOwner, detectTests, detectPRTemplateCheckboxes, + detectDeprecatedComponents, detectRequirements } = require('./detectors'); const { handleReviews } = require('./reviews'); @@ -112,6 +113,7 @@ module.exports = async ({ github, context }) => { codeOwnerLabels, testLabels, checkboxLabels, + deprecatedResult ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), @@ -124,8 +126,13 @@ module.exports = async ({ github, context }) => { detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), detectPRTemplateCheckboxes(context), + detectDeprecatedComponents(github, context, changedFiles) ]); + // Extract deprecated component info + const deprecatedLabels = deprecatedResult.labels; + const deprecatedInfo = deprecatedResult.deprecatedInfo; + // Combine all labels const allLabels = new Set([ ...branchLabels, @@ -139,6 +146,7 @@ module.exports = async ({ github, context }) => { ...codeOwnerLabels, ...testLabels, ...checkboxLabels, + ...deprecatedLabels ]); // Detect requirements based on all other labels @@ -169,7 +177,7 @@ module.exports = async ({ github, context }) => { console.log('Computed labels:', finalLabels.join(', ')); // Handle reviews - await handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); // Apply labels await applyLabels(github, context, finalLabels); diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index a84e9ae5aa..906e2c456a 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -2,12 +2,29 @@ const { BOT_COMMENT_MARKER, CODEOWNERS_MARKER, TOO_BIG_MARKER, + DEPRECATED_COMPONENT_MARKER } = require('./constants'); // Generate review messages -function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { +function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { const messages = []; + // Deprecated component message + if (finalLabels.includes('deprecated-component') && deprecatedInfo && deprecatedInfo.length > 0) { + let message = `${DEPRECATED_COMPONENT_MARKER}\n### ⚠️ Deprecated Component\n\n`; + message += `Hey there @${prAuthor},\n`; + message += `This PR modifies one or more deprecated components. Please be aware:\n\n`; + + for (const info of deprecatedInfo) { + message += `#### Component: \`${info.component}\`\n`; + message += `${info.message}\n\n`; + } + + message += `Consider migrating to the recommended alternative if applicable.`; + + messages.push(message); + } + // Too big message if (finalLabels.includes('too-big')) { const testAdditions = prFiles @@ -54,14 +71,14 @@ function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalA } // Handle reviews -async function handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { +async function handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { const { owner, repo } = context.repo; const pr_number = context.issue.number; const prAuthor = context.payload.pull_request.user.login; - const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); const hasReviewableLabels = finalLabels.some(label => - ['too-big', 'needs-codeowners'].includes(label) + ['too-big', 'needs-codeowners', 'deprecated-component'].includes(label) ); const { data: reviews } = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f521b07bae..841c297bce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: venv # yamllint disable-line rule:line-length @@ -157,7 +157,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -193,7 +193,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -223,7 +223,7 @@ jobs: echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -245,7 +245,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -334,14 +334,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -413,14 +413,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -502,14 +502,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -735,7 +735,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -759,7 +759,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -800,7 +800,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -847,7 +847,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} diff --git a/CODEOWNERS b/CODEOWNERS index 00a22fed7c..1d165a6f57 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -104,6 +104,7 @@ esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke esphome/components/ch422g/* @clydebarrow @jesterret +esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 esphome/components/climate/* @esphome/core esphome/components/climate_ir/* @glmnet @@ -133,6 +134,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 +esphome/components/dlms_meter/* @SimonFischer04 esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 64dd22b0c3..bab2762f00 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -118,6 +118,9 @@ async def to_code(config): cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) if CORE.is_esp32: + # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) + include_builtin_idf_component("esp_adc") + if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": cg.add(var.set_autorange(cg.global_ns.true)) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 6c721652e1..f48b776ddd 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import add_idf_component, include_builtin_idf_component import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE import esphome.final_validate as fv @@ -166,6 +166,9 @@ def final_validate_audio_schema( async def to_code(config): + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + include_builtin_idf_component("esp_http_client") + add_idf_component( name="esphome/esp-audio-libs", ref="2.0.3", diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py new file mode 100644 index 0000000000..e3990ee631 --- /dev/null +++ b/esphome/components/ch423/__init__.py @@ -0,0 +1,103 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.i2c import I2CBus +import esphome.config_validation as cv +from esphome.const import ( + CONF_I2C_ID, + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OPEN_DRAIN, + CONF_OUTPUT, +) +from esphome.core import CORE + +CODEOWNERS = ["@dwmw2"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True +ch423_ns = cg.esphome_ns.namespace("ch423") + +CH423Component = ch423_ns.class_("CH423Component", cg.Component, i2c.I2CDevice) +CH423GPIOPin = ch423_ns.class_( + "CH423GPIOPin", cg.GPIOPin, cg.Parented.template(CH423Component) +) + +CONF_CH423 = "ch423" + +# Note that no address is configurable - each register in the CH423 has a dedicated i2c address +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(CH423Component), + cv.GenerateID(CONF_I2C_ID): cv.use_id(I2CBus), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + # Can't use register_i2c_device because there is no CONF_ADDRESS + parent = await cg.get_variable(config[CONF_I2C_ID]) + cg.add(var.set_i2c_bus(parent)) + + +# This is used as a final validation step so that modes have been fully transformed. +def pin_mode_check(pin_config, _): + if pin_config[CONF_MODE][CONF_INPUT] and pin_config[CONF_NUMBER] >= 8: + raise cv.Invalid("CH423 only supports input on pins 0-7") + if pin_config[CONF_MODE][CONF_OPEN_DRAIN] and pin_config[CONF_NUMBER] < 8: + raise cv.Invalid("CH423 only supports open drain output on pins 8-23") + + ch423_id = pin_config[CONF_CH423] + pin_num = pin_config[CONF_NUMBER] + is_output = pin_config[CONF_MODE][CONF_OUTPUT] + is_open_drain = pin_config[CONF_MODE][CONF_OPEN_DRAIN] + + # Track pin modes per CH423 instance in CORE.data + ch423_modes = CORE.data.setdefault(CONF_CH423, {}) + if ch423_id not in ch423_modes: + ch423_modes[ch423_id] = {"gpio_output": None, "gpo_open_drain": None} + + if pin_num < 8: + # GPIO pins (0-7): all must have same direction + if ch423_modes[ch423_id]["gpio_output"] is None: + ch423_modes[ch423_id]["gpio_output"] = is_output + elif ch423_modes[ch423_id]["gpio_output"] != is_output: + raise cv.Invalid( + "CH423 GPIO pins (0-7) must all be configured as input or all as output" + ) + # GPO pins (8-23): all must have same open-drain setting + elif ch423_modes[ch423_id]["gpo_open_drain"] is None: + ch423_modes[ch423_id]["gpo_open_drain"] = is_open_drain + elif ch423_modes[ch423_id]["gpo_open_drain"] != is_open_drain: + raise cv.Invalid( + "CH423 GPO pins (8-23) must all be configured as push-pull or all as open-drain" + ) + + +CH423_PIN_SCHEMA = pins.gpio_base_schema( + CH423GPIOPin, + cv.int_range(min=0, max=23), + modes=[CONF_INPUT, CONF_OUTPUT, CONF_OPEN_DRAIN], +).extend( + { + cv.Required(CONF_CH423): cv.use_id(CH423Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check) +async def ch423_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + parent = await cg.get_variable(config[CONF_CH423]) + + cg.add(var.set_parent(parent)) + + num = config[CONF_NUMBER] + cg.add(var.set_pin(num)) + 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/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp new file mode 100644 index 0000000000..4abbbe7adf --- /dev/null +++ b/esphome/components/ch423/ch423.cpp @@ -0,0 +1,148 @@ +#include "ch423.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::ch423 { + +static constexpr uint8_t CH423_REG_SYS = 0x24; // Set system parameters (0x48 >> 1) +static constexpr uint8_t CH423_SYS_IO_OE = 0x01; // IO output enable +static constexpr uint8_t CH423_SYS_OD_EN = 0x04; // Open drain enable for OC pins +static constexpr uint8_t CH423_REG_IO = 0x30; // Write/read IO7-IO0 (0x60 >> 1) +static constexpr uint8_t CH423_REG_IO_RD = 0x26; // Read IO7-IO0 (0x4D >> 1, rounded down) +static constexpr uint8_t CH423_REG_OCL = 0x22; // Write OC7-OC0 (0x44 >> 1) +static constexpr uint8_t CH423_REG_OCH = 0x23; // Write OC15-OC8 (0x46 >> 1) + +static const char *const TAG = "ch423"; + +void CH423Component::setup() { + // set outputs before mode + this->write_outputs_(); + // Set system parameters and check for errors + bool success = this->write_reg_(CH423_REG_SYS, this->sys_params_); + // Only read inputs if pins are configured for input (IO_OE not set) + if (success && !(this->sys_params_ & CH423_SYS_IO_OE)) { + success = this->read_inputs_(); + } + if (!success) { + ESP_LOGE(TAG, "CH423 not detected"); + this->mark_failed(); + return; + } + + ESP_LOGCONFIG(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), + this->status_has_error()); +} + +void CH423Component::loop() { + // Clear all the previously read flags. + this->pin_read_flags_ = 0x00; +} + +void CH423Component::dump_config() { + ESP_LOGCONFIG(TAG, "CH423:"); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void CH423Component::pin_mode(uint8_t pin, gpio::Flags flags) { + if (pin < 8) { + if (flags & gpio::FLAG_OUTPUT) { + this->sys_params_ |= CH423_SYS_IO_OE; + } + } else if (pin >= 8 && pin < 24) { + if (flags & gpio::FLAG_OPEN_DRAIN) { + this->sys_params_ |= CH423_SYS_OD_EN; + } + } +} + +bool CH423Component::digital_read(uint8_t pin) { + if (this->pin_read_flags_ == 0 || this->pin_read_flags_ & (1 << pin)) { + // Read values on first access or in case it's being read again in the same loop + this->read_inputs_(); + } + + this->pin_read_flags_ |= (1 << pin); + return (this->input_bits_ & (1 << pin)) != 0; +} + +void CH423Component::digital_write(uint8_t pin, bool value) { + if (value) { + this->output_bits_ |= (1 << pin); + } else { + this->output_bits_ &= ~(1 << pin); + } + this->write_outputs_(); +} + +bool CH423Component::read_inputs_() { + if (this->is_failed()) { + return false; + } + // reading inputs requires IO_OE to be 0 + if (this->sys_params_ & CH423_SYS_IO_OE) { + return false; + } + uint8_t result = this->read_reg_(CH423_REG_IO_RD); + this->input_bits_ = result; + this->status_clear_warning(); + return true; +} + +// Write a register. Can't use the standard write_byte() method because there is no single pre-configured i2c address. +bool CH423Component::write_reg_(uint8_t reg, uint8_t value) { + auto err = this->bus_->write_readv(reg, &value, 1, nullptr, 0); + if (err != i2c::ERROR_OK) { + char buf[64]; + ESPHOME_snprintf_P(buf, sizeof(buf), ESPHOME_PSTR("write failed for register 0x%X, error %d"), reg, err); + this->status_set_warning(buf); + return false; + } + this->status_clear_warning(); + return true; +} + +uint8_t CH423Component::read_reg_(uint8_t reg) { + uint8_t value; + auto err = this->bus_->write_readv(reg, nullptr, 0, &value, 1); + if (err != i2c::ERROR_OK) { + char buf[64]; + ESPHOME_snprintf_P(buf, sizeof(buf), ESPHOME_PSTR("read failed for register 0x%X, error %d"), reg, err); + this->status_set_warning(buf); + return 0; + } + this->status_clear_warning(); + return value; +} + +bool CH423Component::write_outputs_() { + bool success = true; + // Write IO7-IO0 + success &= this->write_reg_(CH423_REG_IO, static_cast(this->output_bits_)); + // Write OC7-OC0 + success &= this->write_reg_(CH423_REG_OCL, static_cast(this->output_bits_ >> 8)); + // Write OC15-OC8 + success &= this->write_reg_(CH423_REG_OCH, static_cast(this->output_bits_ >> 16)); + return success; +} + +float CH423Component::get_setup_priority() const { return setup_priority::IO; } + +// Run our loop() method very early in the loop, so that we cache read values +// before other components call our digital_read() method. +float CH423Component::get_loop_priority() const { return 9.0f; } // Just after WIFI + +void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } + +void CH423GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } +size_t CH423GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "EXIO%u via CH423", this->pin_); +} +void CH423GPIOPin::set_flags(gpio::Flags flags) { + flags_ = flags; + this->parent_->pin_mode(this->pin_, flags); +} + +} // namespace esphome::ch423 diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h new file mode 100644 index 0000000000..7adc7de6a1 --- /dev/null +++ b/esphome/components/ch423/ch423.h @@ -0,0 +1,67 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ch423 { + +class CH423Component : public Component, public i2c::I2CDevice { + public: + CH423Component() = default; + + /// Check i2c availability and setup masks + void setup() override; + /// Poll for input changes periodically + void loop() override; + /// Helper function to read the value of a pin. + bool digital_read(uint8_t pin); + /// Helper function to write the value of a pin. + void digital_write(uint8_t pin, bool value); + /// Helper function to set the pin mode of a pin. + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + float get_loop_priority() const override; + void dump_config() override; + + protected: + bool write_reg_(uint8_t reg, uint8_t value); + uint8_t read_reg_(uint8_t reg); + bool read_inputs_(); + bool write_outputs_(); + + /// The mask to write as output state - 1 means HIGH, 0 means LOW + uint32_t output_bits_{0x00}; + /// Flags to check if read previously during this loop + uint8_t pin_read_flags_{0x00}; + /// Copy of last read values + uint8_t input_bits_{0x00}; + /// System parameters + uint8_t sys_params_{0x00}; +}; + +/// Helper class to expose a CH423 pin as a GPIO pin. +class CH423GPIOPin : public GPIOPin { + 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_parent(CH423Component *parent) { parent_ = parent; } + void set_pin(uint8_t pin) { pin_ = pin; } + void set_inverted(bool inverted) { inverted_ = inverted; } + void set_flags(gpio::Flags flags); + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + CH423Component *parent_{}; + uint8_t pin_{}; + bool inverted_{}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::ch423 diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index ccbeedcd2f..695e7cde47 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, SCHEDULER_DONT_RUN, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority IS_PLATFORM_COMPONENT = True @@ -222,3 +222,8 @@ async def display_is_displaying_page_to_code(config, condition_id, template_arg, async def to_code(config): cg.add_global(display_ns.using) cg.add_define("USE_DISPLAY") + if CORE.is_esp32: + # Re-enable ESP-IDF's LCD driver (excluded by default to save compile time) + from esphome.components.esp32 import include_builtin_idf_component + + include_builtin_idf_component("esp_lcd") diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py new file mode 100644 index 0000000000..c22ab7b552 --- /dev/null +++ b/esphome/components/dlms_meter/__init__.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 + +CODEOWNERS = ["@SimonFischer04"] +DEPENDENCIES = ["uart"] + +CONF_DLMS_METER_ID = "dlms_meter_id" +CONF_DECRYPTION_KEY = "decryption_key" +CONF_PROVIDER = "provider" + +PROVIDERS = {"generic": 0, "netznoe": 1} + +dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") +DlmsMeterComponent = dlms_meter_component_ns.class_( + "DlmsMeterComponent", cg.Component, uart.UARTDevice +) + + +def validate_key(value): + value = cv.string_strict(value) + if len(value) != 32: + raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") + try: + return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] + except ValueError as exc: + raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DlmsMeterComponent), + cv.Required(CONF_DECRYPTION_KEY): validate_key, + cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( + PROVIDERS, lower=True + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "dlms_meter", baud_rate=2400, require_rx=True +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) + cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) + cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h new file mode 100644 index 0000000000..a3d8f62ce6 --- /dev/null +++ b/esphome/components/dlms_meter/dlms.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +namespace esphome::dlms_meter { + +/* ++-------------------------------+ +| Ciphering Service | ++-------------------------------+ +| System Title Length | ++-------------------------------+ +| | +| | +| | +| System | +| Title | +| | +| | +| | ++-------------------------------+ +| Length | (1 or 3 Bytes) ++-------------------------------+ +| Security Control Byte | ++-------------------------------+ +| | +| Frame | +| Counter | +| | ++-------------------------------+ +| | +~ ~ + Encrypted Payload +~ ~ +| | ++-------------------------------+ + +Ciphering Service: 0xDB (General-Glo-Ciphering) +System Title Length: 0x08 +System Title: Unique ID of meter +Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) +Security Control Byte: +- Bit 3…0: Security_Suite_Id +- Bit 4: "A" subfield: indicates that authentication is applied +- Bit 5: "E" subfield: indicates that encryption is applied +- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast +- Bit 7: Indicates the use of compression. + */ + +static constexpr uint8_t DLMS_HEADER_LENGTH = 16; +static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header +static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; +static constexpr uint8_t DLMS_SYST_OFFSET = 1; +static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; +static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; +static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field +static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; +static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; +static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; +static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; +static constexpr uint8_t GLO_CIPHERING = 0xDB; +static constexpr uint8_t DATA_NOTIFICATION = 0x0F; +static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; +static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). + +// Provider specific quirks +static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE +static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; +static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; + +} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp new file mode 100644 index 0000000000..6aa465143e --- /dev/null +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -0,0 +1,468 @@ +#include "dlms_meter.h" + +#include + +#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) +#include +#elif defined(USE_ESP32) +#include "mbedtls/esp_config.h" +#include "mbedtls/gcm.h" +#endif + +namespace esphome::dlms_meter { + +static constexpr const char *TAG = "dlms_meter"; + +void DlmsMeterComponent::dump_config() { + const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; + ESP_LOGCONFIG(TAG, + "DLMS Meter:\n" + " Provider: %s\n" + " Read Timeout: %u ms", + provider_name, this->read_timeout_); +#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); + DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) +#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); + DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) +} + +void DlmsMeterComponent::loop() { + // Read while data is available, netznoe uses two frames so allow 2x max frame length + while (this->available()) { + if (this->receive_buffer_.size() >= MBUS_MAX_FRAME_LENGTH * 2) { + ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); + break; + } + uint8_t c; + this->read_byte(&c); + this->receive_buffer_.push_back(c); + this->last_read_ = millis(); + } + + if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { + this->mbus_payload_.clear(); + if (!this->parse_mbus_(this->mbus_payload_)) + return; + + uint16_t message_length; + uint8_t systitle_length; + uint16_t header_offset; + if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) + return; + + if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { + ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); + this->receive_buffer_.clear(); + return; + } + + // Decrypt in place and then decode the OBIS codes + if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) + return; + this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + } +} + +bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { + ESP_LOGV(TAG, "Parsing M-Bus frames"); + uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames + + while (frame_offset < this->receive_buffer_.size()) { + // Ensure enough bytes remain for the minimal intro header before accessing indices + if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { + ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, + (this->receive_buffer_.size() - frame_offset)); + this->receive_buffer_.clear(); + return false; + } + + // Check start bytes + if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || + this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { + ESP_LOGE(TAG, "MBUS: Start bytes do not match"); + this->receive_buffer_.clear(); + return false; + } + + // Both length bytes must be identical + if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != + this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { + ESP_LOGE(TAG, "MBUS: Length bytes do not match"); + this->receive_buffer_.clear(); + return false; + } + + uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame + + // Check if received data is enough for the given frame length + if (this->receive_buffer_.size() - frame_offset < + frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte + ESP_LOGE(TAG, "MBUS: Frame too big for received data"); + this->receive_buffer_.clear(); + return false; + } + + // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte + size_t required_total = + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes + if (this->receive_buffer_.size() - frame_offset < required_total) { + ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, + this->receive_buffer_.size() - frame_offset); + this->receive_buffer_.clear(); + return false; + } + + if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != + STOP_BYTE) { + ESP_LOGE(TAG, "MBUS: Invalid stop byte"); + this->receive_buffer_.clear(); + return false; + } + + // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte + uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored + for (uint16_t i = 0; i < frame_length; i++) { + checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; + } + if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { + ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, + this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); + this->receive_buffer_.clear(); + return false; + } + + mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], + &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); + + frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; + } + return true; +} + +bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, + uint8_t &systitle_length, uint16_t &header_offset) { + ESP_LOGV(TAG, "Parsing DLMS header"); + if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { + ESP_LOGE(TAG, "DLMS: Payload too short"); + this->receive_buffer_.clear(); + return false; + } + + if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) + ESP_LOGE(TAG, "DLMS: Unsupported cipher"); + this->receive_buffer_.clear(); + return false; + } + + systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + + if (systitle_length != 0x08) { // Only system titles with length of 8 are supported + ESP_LOGE(TAG, "DLMS: Unsupported system title length"); + this->receive_buffer_.clear(); + return false; + } + + message_length = mbus_payload[DLMS_LENGTH_OFFSET]; + header_offset = 0; + + if (this->provider_ == PROVIDER_NETZNOE) { + // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next + // byte. Check some bytes to see if received data still matches expectation + if (message_length == NETZ_NOE_MAGIC_BYTE && + mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && + mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { + message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; + header_offset = 1; + } else { + ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); + } + } else { + if (message_length == TWO_BYTE_LENGTH) { + message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); + header_offset = DLMS_HEADER_EXT_OFFSET; + } + } + if (message_length < DLMS_LENGTH_CORRECTION) { + ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); + this->receive_buffer_.clear(); + return false; + } + message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length + + if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { + ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), + DLMS_HEADER_LENGTH, header_offset, message_length); + ESP_LOGE(TAG, "DLMS: Message has invalid length"); + this->receive_buffer_.clear(); + return false; + } + + if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && + mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != + 0x20) { // Only certain security suite is supported (0x21 || 0x20) + ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); + this->receive_buffer_.clear(); + return false; + } + + return true; +} + +bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, + uint16_t header_offset) { + ESP_LOGV(TAG, "Decrypting payload"); + uint8_t iv[12]; // Reserve space for the IV, always 12 bytes + // Copy system title to IV (System title is before length; no header offset needed!) + // Add 1 to the offset in order to skip the system title length byte + memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); + memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], + DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV + + uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + +#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) + br_gcm_context gcm_ctx; + br_aes_ct_ctr_keys bc; + br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); + br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); + br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); + br_gcm_flip(&gcm_ctx); + br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); +#elif defined(USE_ESP32) + size_t outlen = 0; + mbedtls_gcm_context gcm_ctx; + mbedtls_gcm_init(&gcm_ctx); + mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); + mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); + auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); + mbedtls_gcm_free(&gcm_ctx); + if (ret != 0) { + ESP_LOGE(TAG, "Decryption failed with error: %d", ret); + this->receive_buffer_.clear(); + return false; + } +#else +#error "Invalid Platform" +#endif + + if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { + ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); + this->receive_buffer_.clear(); + return false; + } + ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); + return true; +} + +void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { + ESP_LOGV(TAG, "Decoding payload"); + MeterData data{}; + uint16_t current_position = DECODER_START_OFFSET; + bool power_factor_found = false; + + while (current_position + OBIS_CODE_OFFSET <= message_length) { + if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { + ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); + this->receive_buffer_.clear(); + return; + } + + uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; + if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { + ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); + this->receive_buffer_.clear(); + return; + } + if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { + ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); + this->receive_buffer_.clear(); + return; + } + + uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; + uint8_t obis_medium = obis_code[OBIS_A]; + uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); + + bool timestamp_found = false; + bool meter_number_found = false; + if (this->provider_ == PROVIDER_NETZNOE) { + // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET + if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { + timestamp_found = true; + } else if (power_factor_found) { + meter_number_found = true; + power_factor_found = false; + } else { + current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position + } + } else { + current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type + } + if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && + obis_medium != Medium::ABSTRACT) { + ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); + this->receive_buffer_.clear(); + return; + } + + if (current_position >= message_length) { + ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); + this->receive_buffer_.clear(); + return; + } + + float value = 0.0f; + uint8_t value_size = 0; + uint8_t data_type = plaintext[current_position]; + current_position++; + + switch (data_type) { + case DataType::DOUBLE_LONG_UNSIGNED: { + value_size = 4; + if (current_position + value_size > message_length) { + ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); + this->receive_buffer_.clear(); + return; + } + value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], + plaintext[current_position + 2], plaintext[current_position + 3]); + current_position += value_size; + break; + } + case DataType::LONG_UNSIGNED: { + value_size = 2; + if (current_position + value_size > message_length) { + ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); + this->receive_buffer_.clear(); + return; + } + value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); + current_position += value_size; + break; + } + case DataType::OCTET_STRING: { + uint8_t data_length = plaintext[current_position]; + current_position++; // Advance past string length + if (current_position + data_length > message_length) { + ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); + this->receive_buffer_.clear(); + return; + } + // Handle timestamp (normal OBIS code or NETZNOE special case) + if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { + if (data_length < 8) { + ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); + this->receive_buffer_.clear(); + return; + } + uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); + uint8_t month = plaintext[current_position + 2]; + uint8_t day = plaintext[current_position + 3]; + uint8_t hour = plaintext[current_position + 5]; + uint8_t minute = plaintext[current_position + 6]; + uint8_t second = plaintext[current_position + 7]; + if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { + ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, + second); + this->receive_buffer_.clear(); + return; + } + snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, + minute, second); + } else if (meter_number_found) { + snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); + } + current_position += data_length; + break; + } + default: + ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); + this->receive_buffer_.clear(); + return; + } + + // Skip break after data + if (this->provider_ == PROVIDER_NETZNOE) { + // Don't skip the break on the first timestamp, as there's none + if (!timestamp_found) { + current_position += 2; + } + } else { + current_position += 2; + } + + // Check for additional data (scaler-unit structure) + if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { + // Apply scaler: real_value = raw_value × 10^scaler + if (current_position + 1 < message_length) { + int8_t scaler = static_cast(plaintext[current_position + 1]); + if (scaler != 0) { + value *= powf(10.0f, scaler); + } + } + + // on EVN Meters there is no additional break + if (this->provider_ == PROVIDER_NETZNOE) { + current_position += 4; + } else { + current_position += 6; + } + } + + // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) + if (value_size > 0) { + switch (obis_cd) { + case OBIS_VOLTAGE_L1: + data.voltage_l1 = value; + break; + case OBIS_VOLTAGE_L2: + data.voltage_l2 = value; + break; + case OBIS_VOLTAGE_L3: + data.voltage_l3 = value; + break; + case OBIS_CURRENT_L1: + data.current_l1 = value; + break; + case OBIS_CURRENT_L2: + data.current_l2 = value; + break; + case OBIS_CURRENT_L3: + data.current_l3 = value; + break; + case OBIS_ACTIVE_POWER_PLUS: + data.active_power_plus = value; + break; + case OBIS_ACTIVE_POWER_MINUS: + data.active_power_minus = value; + break; + case OBIS_ACTIVE_ENERGY_PLUS: + data.active_energy_plus = value; + break; + case OBIS_ACTIVE_ENERGY_MINUS: + data.active_energy_minus = value; + break; + case OBIS_REACTIVE_ENERGY_PLUS: + data.reactive_energy_plus = value; + break; + case OBIS_REACTIVE_ENERGY_MINUS: + data.reactive_energy_minus = value; + break; + case OBIS_POWER_FACTOR: + data.power_factor = value; + power_factor_found = true; + break; + default: + ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); + } + } + } + + this->receive_buffer_.clear(); + + ESP_LOGI(TAG, "Received valid data"); + this->publish_sensors(data); + this->status_clear_warning(); +} + +} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h new file mode 100644 index 0000000000..c50e6f6b4d --- /dev/null +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -0,0 +1,96 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include "mbus.h" +#include "dlms.h" +#include "obis.h" + +#include +#include + +namespace esphome::dlms_meter { + +#ifndef DLMS_METER_SENSOR_LIST +#define DLMS_METER_SENSOR_LIST(F, SEP) +#endif + +#ifndef DLMS_METER_TEXT_SENSOR_LIST +#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) +#endif + +struct MeterData { + float voltage_l1 = 0.0f; // Voltage L1 + float voltage_l2 = 0.0f; // Voltage L2 + float voltage_l3 = 0.0f; // Voltage L3 + float current_l1 = 0.0f; // Current L1 + float current_l2 = 0.0f; // Current L2 + float current_l3 = 0.0f; // Current L3 + float active_power_plus = 0.0f; // Active power taken from grid + float active_power_minus = 0.0f; // Active power put into grid + float active_energy_plus = 0.0f; // Active energy taken from grid + float active_energy_minus = 0.0f; // Active energy put into grid + float reactive_energy_plus = 0.0f; // Reactive energy taken from grid + float reactive_energy_minus = 0.0f; // Reactive energy put into grid + char timestamp[27]{}; // Text sensor for the timestamp value + + // Netz NOE + float power_factor = 0.0f; // Power Factor + char meternumber[13]{}; // Text sensor for the meterNumber value +}; + +// Provider constants +enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; + +class DlmsMeterComponent : public Component, public uart::UARTDevice { + public: + DlmsMeterComponent() = default; + + void dump_config() override; + void loop() override; + + void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } + void set_provider(uint32_t provider) { this->provider_ = provider; } + + void publish_sensors(MeterData &data) { +#define DLMS_METER_PUBLISH_SENSOR(s) \ + if (this->s##_sensor_ != nullptr) \ + s##_sensor_->publish_state(data.s); + DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) + +#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ + if (this->s##_text_sensor_ != nullptr) \ + s##_text_sensor_->publish_state(data.s); + DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) + } + + DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) + DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) + + protected: + bool parse_mbus_(std::vector &mbus_payload); + bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, + uint16_t &header_offset); + bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, + uint16_t header_offset); + void decode_obis_(uint8_t *plaintext, uint16_t message_length); + + std::vector receive_buffer_; // Stores the packet currently being received + std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn + uint32_t last_read_ = 0; // Timestamp when data was last read + uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + + uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator + std::array decryption_key_; +}; + +} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h new file mode 100644 index 0000000000..293d43a55b --- /dev/null +++ b/esphome/components/dlms_meter/mbus.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +namespace esphome::dlms_meter { + +/* ++----------------------------------------------------+ - +| Start Character [0x68] | \ ++----------------------------------------------------+ | +| Data Length (L) | | ++----------------------------------------------------+ | +| Data Length Repeat (L) | | ++----------------------------------------------------+ > M-Bus Data link layer +| Start Character Repeat [0x68] | | ++----------------------------------------------------+ | +| Control/Function Field (C) | | ++----------------------------------------------------+ | +| Address Field (A) | / ++----------------------------------------------------+ - +| Control Information Field (CI) | \ ++----------------------------------------------------+ | +| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer ++----------------------------------------------------+ | +| Destination Transport Service Access Point (DTSAP) | / ++----------------------------------------------------+ - +| | \ +~ ~ | + Data > DLMS/COSEM Application Layer +~ ~ | +| | / ++----------------------------------------------------+ - +| Checksum | \ ++----------------------------------------------------+ > M-Bus Data link layer +| Stop Character [0x16] | / ++----------------------------------------------------+ - + +Data_Length = L - C - A - CI +Each line (except Data) is one Byte + +Possible Values found in publicly available docs: +- C: 0x53/0x73 (SND_UD) +- A: FF (Broadcast) +- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D +- STSAP: 0x01 (Management Logical Device ID 1 of the meter) +- DTSAP: 0x67 (Consumer Information Push Client ID 103) + */ + +// MBUS start bytes for different telegram formats: +// - Single Character: 0xE5 (length=1) +// - Short Frame: 0x10 (length=5) +// - Control Frame: 0x68 (length=9) +// - Long Frame: 0x68 (length=9+data_length) +// This component currently only uses Long Frame. +static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; +static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; +static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; +static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; +static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) +static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length +static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame +static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame +static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte +static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte +static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte +static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte +static constexpr uint8_t STOP_BYTE = 0x16; + +} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h new file mode 100644 index 0000000000..1bb960e61e --- /dev/null +++ b/esphome/components/dlms_meter/obis.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace esphome::dlms_meter { + +// Data types as per specification +enum DataType { + NULL_DATA = 0x00, + BOOLEAN = 0x03, + BIT_STRING = 0x04, + DOUBLE_LONG = 0x05, + DOUBLE_LONG_UNSIGNED = 0x06, + OCTET_STRING = 0x09, + VISIBLE_STRING = 0x0A, + UTF8_STRING = 0x0C, + BINARY_CODED_DECIMAL = 0x0D, + INTEGER = 0x0F, + LONG = 0x10, + UNSIGNED = 0x11, + LONG_UNSIGNED = 0x12, + LONG64 = 0x14, + LONG64_UNSIGNED = 0x15, + ENUM = 0x16, + FLOAT32 = 0x17, + FLOAT64 = 0x18, + DATE_TIME = 0x19, + DATE = 0x1A, + TIME = 0x1B, + + ARRAY = 0x01, + STRUCTURE = 0x02, + COMPACT_ARRAY = 0x13 +}; + +enum Medium { + ABSTRACT = 0x00, + ELECTRICITY = 0x01, + HEAT_COST_ALLOCATOR = 0x04, + COOLING = 0x05, + HEAT = 0x06, + GAS = 0x07, + COLD_WATER = 0x08, + HOT_WATER = 0x09, + OIL = 0x10, + COMPRESSED_AIR = 0x11, + NITROGEN = 0x12 +}; + +// Data structure +static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block +static constexpr uint8_t OBIS_TYPE_OFFSET = 0; +static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; +static constexpr uint8_t OBIS_CODE_OFFSET = 2; +static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) +static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code +static constexpr uint8_t OBIS_A = 0; +static constexpr uint8_t OBIS_B = 1; +static constexpr uint8_t OBIS_C = 2; +static constexpr uint8_t OBIS_D = 3; +static constexpr uint8_t OBIS_E = 4; +static constexpr uint8_t OBIS_F = 5; + +// Metadata +static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; +static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; +static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; + +// Voltage +static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; +static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; +static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; + +// Current +static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; +static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; +static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; + +// Power +static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; +static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; + +// Active energy +static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; +static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; + +// Reactive energy +static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; +static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; + +// Netz NOE specific +static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; + +} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py new file mode 100644 index 0000000000..27fd44f008 --- /dev/null +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -0,0 +1,124 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) + +from .. import CONF_DLMS_METER_ID, DlmsMeterComponent + +AUTO_LOAD = ["dlms_meter"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + # Netz NOE + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + + sensors = [] + for key, conf in config.items(): + if not isinstance(conf, dict): + continue + id = conf[CONF_ID] + if id and id.type == sensor.Sensor: + sens = await sensor.new_sensor(conf) + cg.add(getattr(hub, f"set_{key}_sensor")(sens)) + sensors.append(f"F({key})") + + if sensors: + cg.add_define( + "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) + ) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py new file mode 100644 index 0000000000..4d2373f4f9 --- /dev/null +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import CONF_DLMS_METER_ID, DlmsMeterComponent + +AUTO_LOAD = ["dlms_meter"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + # Netz NOE + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + + text_sensors = [] + for key, conf in config.items(): + if not isinstance(conf, dict): + continue + id = conf[CONF_ID] + if id and id.type == text_sensor.TextSensor: + sens = await text_sensor.new_text_sensor(conf) + cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) + text_sensors.append(f"F({key})") + + if text_sensors: + cg.add_define( + "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", + cg.RawExpression(" sep ".join(text_sensors)), + ) diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index b9d8eae6b0..c5fb6096da 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -2,7 +2,8 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_AUDIO_DAC, CONF_BITS_PER_SAMPLE, CONF_ID +import esphome.final_validate as fv CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -21,6 +22,29 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config): + full_config = fv.full_config.get() + + # Check all speaker configurations for ones that reference this es8156 + speaker_configs = full_config.get("speaker", []) + for speaker_config in speaker_configs: + audio_dac_id = speaker_config.get(CONF_AUDIO_DAC) + if ( + audio_dac_id is not None + and audio_dac_id == config[CONF_ID] + and (bits_per_sample := speaker_config.get(CONF_BITS_PER_SAMPLE)) + is not None + and bits_per_sample > 24 + ): + raise cv.Invalid( + f"ES8156 does not support more than 24 bits per sample. " + f"The speaker referencing this audio_dac has bits_per_sample set to {bits_per_sample}." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index e84252efe2..961dc24b29 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -17,24 +17,61 @@ static const char *const TAG = "es8156"; } void ES8156::setup() { + // REG02 MODE CONFIG 1: Enable software mode for I2C control of volume/mute + // Bit 2: SOFT_MODE_SEL=1 (software mode enabled) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG02_SCLK_MODE, 0x04)); + + // Analog system configuration (active-low power down bits, active-high enables) + // REG20 ANALOG SYSTEM: Configure analog signal path ES8156_ERROR_FAILED(this->write_byte(ES8156_REG20_ANALOG_SYS1, 0x2A)); + + // REG21 ANALOG SYSTEM: VSEL=0x1C (bias level ~120%), normal VREF ramp speed ES8156_ERROR_FAILED(this->write_byte(ES8156_REG21_ANALOG_SYS2, 0x3C)); + + // REG22 ANALOG SYSTEM: Line out mode (HPSW=0), OUT_MUTE=0 (not muted) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG22_ANALOG_SYS3, 0x00)); + + // REG24 ANALOG SYSTEM: Low power mode for VREFBUF, HPCOM, DACVRP; DAC normal power + // Bits 2:0 = 0x07: LPVREFBUF=1, LPHPCOM=1, LPDACVRP=1, LPDAC=0 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG24_ANALOG_LP, 0x07)); + + // REG23 ANALOG SYSTEM: Lowest bias (IBIAS_SW=0), VMIDLVL=VDDA/2, normal impedance ES8156_ERROR_FAILED(this->write_byte(ES8156_REG23_ANALOG_SYS4, 0x00)); + // Timing and interface configuration + // REG0A/0B TIME CONTROL: Fast state machine transitions ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0A_TIME_CONTROL1, 0x01)); ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0B_TIME_CONTROL2, 0x01)); + + // REG11 SDP INTERFACE CONFIG: Default I2S format (24-bit, I2S mode) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG11_DAC_SDP, 0x00)); + + // REG19 EQ CONTROL 1: EQ disabled (EQ_ON=0), EQ_BAND_NUM=2 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG19_EQ_CONTROL1, 0x20)); + // REG0D P2S CONTROL: Parallel-to-serial converter settings ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0D_P2S_CONTROL, 0x14)); + + // REG09 MISC CONTROL 2: Default settings ES8156_ERROR_FAILED(this->write_byte(ES8156_REG09_MISC_CONTROL2, 0x00)); + + // REG18 MISC CONTROL 3: Stereo channel routing, no inversion + // Bits 5:4 CHN_CROSS: 0=L→L/R→R, 1=L to both, 2=R to both, 3=swap L/R + // Bits 3:2: LCH_INV/RCH_INV channel inversion ES8156_ERROR_FAILED(this->write_byte(ES8156_REG18_MISC_CONTROL3, 0x00)); + + // REG08 CLOCK OFF: Enable all internal clocks (0x3F = all clock gates open) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG08_CLOCK_ON_OFF, 0x3F)); + + // REG00 RESET CONTROL: Reset sequence + // First: RST_DIG=1 (assert digital reset) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x02)); + // Then: CSM_ON=1 (enable chip state machine), RST_DIG=1 ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x03)); + + // REG25 ANALOG SYSTEM: Power up analog blocks + // VMIDSEL=2 (normal VMID operation), PDN_ANA=0, ENREFR=0, ENHPCOM=0 + // PDN_DACVREFGEN=0, PDN_VREFBUF=0, PDN_DAC=0 (all enabled) ES8156_ERROR_FAILED(this->write_byte(ES8156_REG25_ANALOG_SYS5, 0x20)); } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fccf0ed09f..aed4ecad90 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -53,6 +53,7 @@ from .const import ( # noqa KEY_BOARD, KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_EXTRA_BUILD_FILES, KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, @@ -86,6 +87,7 @@ IS_TARGET_PLATFORM = True CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" +CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" @@ -114,6 +116,36 @@ COMPILER_OPTIMIZATIONS = { "SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE", } +# ESP-IDF components excluded by default to reduce compile time. +# Components can be re-enabled by calling include_builtin_idf_component() in to_code(). +# +# Cannot be excluded (dependencies of required components): +# - "console": espressif/mdns unconditionally depends on it +# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +DEFAULT_EXCLUDED_IDF_COMPONENTS = ( + "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "esp_adc", # ADC driver - only needed by adc component + "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch + "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality + "esp_http_client", # HTTP client - only needed by http_request component + "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation + "esp_https_server", # HTTPS server - ESPHome has its own web server + "esp_lcd", # LCD controller drivers - only needed by display component + "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API + "espcoredump", # Core dump support - ESPHome has its own debug component + "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation + "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protocomm", # Protocol communication for provisioning - unused by ESPHome + "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "unity", # Unit testing framework - ESPHome doesn't use IDF's testing + "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused + "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation +) + # ESP32 (original) chip revision options # Setting minimum revision to 3.0 or higher: # - Reduces flash size by excluding workaround code for older chip bugs @@ -203,6 +235,9 @@ def set_core_data(config): ) CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] = {} CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} + # Initialize with default exclusions - components can call include_builtin_idf_component() + # to re-enable any they need + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(DEFAULT_EXCLUDED_IDF_COMPONENTS) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) @@ -328,6 +363,28 @@ def add_idf_component( } +def exclude_builtin_idf_component(name: str) -> None: + """Exclude an ESP-IDF component from the build. + + This reduces compile time by skipping components that are not needed. + The component will be passed to ESP-IDF's EXCLUDE_COMPONENTS cmake variable. + + Note: Components that are dependencies of other required components + cannot be excluded - ESP-IDF will still build them. + """ + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].add(name) + + +def include_builtin_idf_component(name: str) -> None: + """Remove an ESP-IDF component from the exclusion list. + + Call this from components that need an ESP-IDF component that is + excluded by default in DEFAULT_EXCLUDED_IDF_COMPONENTS. This ensures the + component will be built when needed. + """ + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) + + def add_extra_script(stage: str, filename: str, path: Path): """Add an extra script to the project.""" key = f"{stage}:{filename}" @@ -672,11 +729,26 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" CONF_HEAP_IN_IRAM = "heap_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle" +CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" +CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" +CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" +CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" +CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" +CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" +CONF_DISABLE_FATFS = "disable_fatfs" # VFS requirement tracking -# Components that need VFS features can call require_vfs_select() or require_vfs_dir() +# Components that need VFS features can call require_vfs_*() functions KEY_VFS_SELECT_REQUIRED = "vfs_select_required" KEY_VFS_DIR_REQUIRED = "vfs_dir_required" +KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" +# Feature requirement tracking - components can call require_* functions to re-enable +# These are stored in CORE.data[KEY_ESP32] dict +KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" +KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" +KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_FATFS_REQUIRED = "fatfs_required" def require_vfs_select() -> None: @@ -697,6 +769,15 @@ def require_vfs_dir() -> None: CORE.data[KEY_VFS_DIR_REQUIRED] = True +def require_vfs_termios() -> None: + """Mark that VFS termios support is required by a component. + + Call this from components that use terminal I/O functions (usb_serial_jtag_vfs_*, etc.). + This prevents CONFIG_VFS_SUPPORT_TERMIOS from being disabled. + """ + CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True + + def require_full_certificate_bundle() -> None: """Request the full certificate bundle instead of the common-CAs-only bundle. @@ -709,6 +790,43 @@ def require_full_certificate_bundle() -> None: CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True +def require_usb_serial_jtag_secondary() -> None: + """Mark that USB Serial/JTAG secondary console is required by a component. + + Call this from components (e.g., logger) that need USB Serial/JTAG console output. + This prevents CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG from being disabled. + """ + CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True + + +def require_mbedtls_peer_cert() -> None: + """Mark that mbedTLS peer certificate retention is required by a component. + + Call this from components that need access to the peer certificate after + the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE + from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + + +def require_mbedtls_pkcs7() -> None: + """Mark that mbedTLS PKCS#7 support is required by a component. + + Call this from components that need PKCS#7 certificate validation. + This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + + +def require_fatfs() -> None: + """Mark that FATFS support is required by a component. + + Call this from components that use FATFS (e.g., SD card, storage components). + This prevents FATFS from being disabled when disable_fatfs is set. + """ + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -793,6 +911,19 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, + cv.Optional( + CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[] + ): cv.ensure_list(cv.string_strict), + cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean, + cv.Optional( + CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -982,6 +1113,19 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +@coroutine_with_priority(CoroPriority.FINAL) +async def _write_exclude_components() -> None: + """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" + if KEY_ESP32 not in CORE.data: + return + excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) + if excluded: + exclude_list = ";".join(sorted(excluded)) + cg.add_platformio_option( + "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -1185,6 +1329,10 @@ async def to_code(config): # Disable dynamic log level control to save memory add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) + # Disable per-tag log level filtering since dynamic level control is disabled above + # This saves ~250 bytes of RAM (tag cache) and associated code + add_idf_sdkconfig_option("CONFIG_LOG_TAG_LEVEL_IMPL_NONE", True) + # Reduce PHY TX power in the event of a brownout add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) @@ -1195,6 +1343,11 @@ async def to_code(config): # Apply LWIP optimization settings advanced = conf[CONF_ADVANCED] + + # Re-include any IDF components the user explicitly requested + for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): + include_builtin_idf_component(component_name) + # DHCP server: only disable if explicitly set to false # WiFi component handles its own optimization when AP mode is not used # When using Arduino with Ethernet, DHCP server functions must be available @@ -1233,11 +1386,18 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) # Disable VFS support for termios (terminal I/O functions) - # ESPHome doesn't use termios functions on ESP32 (only used in host UART driver). + # USB Serial JTAG VFS functions require termios support. + # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected + # as the logger output) call require_vfs_termios(). # Saves approximately 1.8KB of flash when disabled (default). - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + # Component requires VFS termios - force enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + # No component needs it - allow user to control (default: disabled) + add_idf_sdkconfig_option( + "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] + ) # Disable VFS support for select() with file descriptors # ESPHome only uses select() with sockets via lwip_select(), which still works. @@ -1316,6 +1476,61 @@ async def to_code(config): add_idf_sdkconfig_option(f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True) + # Disable OpenOCD debug stubs to save code size + # These are used for on-chip debugging with OpenOCD/JTAG, rarely needed for ESPHome + if advanced[CONF_DISABLE_DEBUG_STUBS]: + add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_STUBS_ENABLE", False) + + # Disable OCD-aware exception handlers + # When enabled, the panic handler detects JTAG debugger and halts instead of resetting + # Most ESPHome users don't use JTAG debugging + if advanced[CONF_DISABLE_OCD_AWARE]: + add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_OCDAWARE", False) + + # Disable USB Serial/JTAG secondary console + # Components like logger can call require_usb_serial_jtag_secondary() to re-enable + if CORE.data[KEY_ESP32].get(KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED, False): + add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG", True) + elif advanced[CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY]: + add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_NONE", True) + + # Disable /dev/null VFS initialization + # ESPHome doesn't typically need /dev/null + if advanced[CONF_DISABLE_DEV_NULL_VFS]: + add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False) + + # Disable keeping peer certificate after TLS handshake + # Saves ~4KB heap per connection, but prevents certificate inspection after handshake + # Components that need it can call require_mbedtls_peer_cert() + if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]: + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + # Disable PKCS#7 support in mbedTLS + # Only needed for specific certificate validation scenarios + # Components that need it can call require_mbedtls_pkcs7() + if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False): + # Component called require_mbedtls_pkcs7() - enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True) + elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) + + # Disable regi2c control functions in IRAM + # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled + if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: + add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + + # Disable FATFS support + # Components that need FATFS (SD card, etc.) can call require_fatfs() + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + # Component called require_fatfs() - enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) + elif advanced[CONF_DISABLE_FATFS]: + add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) + for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) @@ -1324,6 +1539,11 @@ async def to_code(config): if conf[CONF_COMPONENTS]: CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS]) + # Write EXCLUDE_COMPONENTS at FINAL priority after all components have had + # a chance to call include_builtin_idf_component() to re-enable components they need. + # Default exclusions are added in set_core_data() during config validation. + CORE.add_job(_write_exclude_components) + APP_PARTITION_SIZES = { "2MB": 0x0C0000, # 768 KB diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 9f8165818b..db3eddebd5 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -6,6 +6,7 @@ KEY_FLASH_SIZE = "flash_size" KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" +KEY_EXCLUDE_COMPONENTS = "exclude_components" KEY_REPO = "repo" KEY_REF = "ref" KEY_REFRESH = "refresh" diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 3be3c758f1..6d41f6b5b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -5,6 +5,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import esp32, light from esphome.components.const import CONF_USE_PSRAM +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -129,6 +130,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index c54ed8b9ea..6accb89c35 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -6,6 +6,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, get_esp32_variant, gpio, + include_builtin_idf_component, ) import esphome.config_validation as cv from esphome.const import ( @@ -266,6 +267,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + # Re-enable ESP-IDF's touch sensor driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_touch_sens") + touch = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(touch, config) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 7633cd2430..9873a9af10 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + include_builtin_idf_component, ) from esphome.components.network import ip_address_literal from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface @@ -423,6 +424,9 @@ async def to_code(config): # Also disable WiFi/BT coexistence since WiFi is disabled add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) + include_builtin_idf_component("esp_eth") + if config[CONF_TYPE] == "LAN8670": # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 07bc758037..64d74323d6 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -155,6 +155,9 @@ async def to_code(config): cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + esp32.include_builtin_idf_component("esp_http_client") + cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) cg.add(var.set_verify_ssl(config[CONF_VERIFY_SSL])) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index c1e7336ce4..b9b5d79428 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -11,12 +11,6 @@ namespace i2c { static const char *const TAG = "i2c"; void I2CBus::i2c_scan_() { - // suppress logs from the IDF I2C library during the scan -#if defined(USE_ESP32) && defined(USE_LOGGER) - auto previous = esp_log_level_get("*"); - esp_log_level_set("*", ESP_LOG_NONE); -#endif - for (uint8_t address = 8; address != 120; address++) { auto err = write_readv(address, nullptr, 0, nullptr, 0); if (err == ERROR_OK) { @@ -27,9 +21,6 @@ void I2CBus::i2c_scan_() { // it takes 16sec to scan on nrf52. It prevents board reset. arch_feed_wdt(); } -#if defined(USE_ESP32) && defined(USE_LOGGER) - esp_log_level_set("*", previous); -#endif } ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index d3128c5f4c..1cd2e97a5e 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -1,6 +1,11 @@ from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + get_esp32_variant, + include_builtin_idf_component, +) +from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -10,8 +15,6 @@ from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - add_idf_sdkconfig_option, - get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE @@ -272,6 +275,10 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + + # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2s") + if use_legacy(): cg.add_define("USE_I2S_LEGACY") diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index cadd0a14ae..40ceaec7dc 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -16,6 +16,8 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, add_idf_sdkconfig_option, get_esp32_variant, + require_usb_serial_jtag_secondary, + require_vfs_termios, ) from esphome.components.libretiny import get_libretiny_component, get_libretiny_family from esphome.components.libretiny.const import ( @@ -397,9 +399,15 @@ async def to_code(config): elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True) cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG") + # Define platform support flags for components that need auto-detection try: uart_selection(USB_SERIAL_JTAG) cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") + # USB Serial JTAG code is compiled when platform supports it. + # Enable secondary USB serial JTAG console so the VFS functions are available. + if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG: + require_usb_serial_jtag_secondary() + require_vfs_termios() except cv.Invalid: pass try: diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 32ef752462..9defb6c166 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -114,9 +114,6 @@ void Logger::pre_setup() { global_logger = this; esp_log_set_vprintf(esp_idf_log_vprintf_); - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { - esp_log_level_set("*", ESP_LOG_VERBOSE); - } ESP_LOGI(TAG, "Log initialized"); } diff --git a/esphome/components/max7219/display.py b/esphome/components/max7219/display.py index a434125148..abb20702bd 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -28,11 +28,10 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + var = cg.new_Pvariable(config[CONF_ID], config[CONF_NUM_CHIPS]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) - cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) diff --git a/esphome/components/max7219/max7219.cpp b/esphome/components/max7219/max7219.cpp index 157b317c02..d701e6fc86 100644 --- a/esphome/components/max7219/max7219.cpp +++ b/esphome/components/max7219/max7219.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace max7219 { +namespace esphome::max7219 { static const char *const TAG = "max7219"; @@ -115,12 +114,14 @@ const uint8_t MAX7219_ASCII_TO_RAW[95] PROGMEM = { }; float MAX7219Component::get_setup_priority() const { return setup_priority::PROCESSOR; } + +MAX7219Component::MAX7219Component(uint8_t num_chips) : num_chips_(num_chips) { + this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT + memset(this->buffer_, 0, this->num_chips_ * 8); +} + void MAX7219Component::setup() { this->spi_setup(); - this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT - for (uint8_t i = 0; i < this->num_chips_ * 8; i++) - this->buffer_[i] = 0; - // let's assume the user has all 8 digits connected, only important in daisy chained setups anyway this->send_to_all_(MAX7219_REGISTER_SCAN_LIMIT, 7); // let's use our own ASCII -> led pattern encoding @@ -229,7 +230,6 @@ void MAX7219Component::set_intensity(uint8_t intensity) { this->intensity_ = intensity; } } -void MAX7219Component::set_num_chips(uint8_t num_chips) { this->num_chips_ = num_chips; } uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time) { char buffer[64]; @@ -240,5 +240,4 @@ uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time } uint8_t MAX7219Component::strftime(const char *format, ESPTime time) { return this->strftime(0, format, time); } -} // namespace max7219 -} // namespace esphome +} // namespace esphome::max7219 diff --git a/esphome/components/max7219/max7219.h b/esphome/components/max7219/max7219.h index 58d871d54c..ef38628f28 100644 --- a/esphome/components/max7219/max7219.h +++ b/esphome/components/max7219/max7219.h @@ -6,8 +6,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" -namespace esphome { -namespace max7219 { +namespace esphome::max7219 { class MAX7219Component; @@ -17,6 +16,8 @@ class MAX7219Component : public PollingComponent, public spi::SPIDevice { public: + explicit MAX7219Component(uint8_t num_chips); + void set_writer(max7219_writer_t &&writer); void setup() override; @@ -30,7 +31,6 @@ class MAX7219Component : public PollingComponent, void display(); void set_intensity(uint8_t intensity); - void set_num_chips(uint8_t num_chips); void set_reverse(bool reverse) { this->reverse_ = reverse; }; /// Evaluate the printf-format and print the result at the given position. @@ -56,10 +56,9 @@ class MAX7219Component : public PollingComponent, uint8_t intensity_{15}; // Intensity of the display from 0 to 15 (most) bool intensity_changed_{}; // True if we need to re-send the intensity uint8_t num_chips_{1}; - uint8_t *buffer_; + uint8_t *buffer_{nullptr}; bool reverse_{false}; max7219_writer_t writer_{}; }; -} // namespace max7219 -} // namespace esphome +} // namespace esphome::max7219 diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 3123f3b604..3e997402bc 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,6 +12,10 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; static void register_esp32(MDNSComponent *comp, StaticVector &services) { +#ifdef USE_OPENTHREAD + // OpenThread handles service registration via SRP client + // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent +#else esp_err_t err = mdns_init(); if (err != ESP_OK) { ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err)); @@ -41,13 +45,16 @@ static void register_esp32(MDNSComponent *comp, StaticVectorsetup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { +#ifndef USE_OPENTHREAD mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent +#endif } } // namespace esphome::mdns diff --git a/esphome/components/mipi_spi/mipi_spi.cpp b/esphome/components/mipi_spi/mipi_spi.cpp index 272915b4e1..90f6324511 100644 --- a/esphome/components/mipi_spi/mipi_spi.cpp +++ b/esphome/components/mipi_spi/mipi_spi.cpp @@ -1,6 +1,39 @@ #include "mipi_spi.h" #include "esphome/core/log.h" -namespace esphome { -namespace mipi_spi {} // namespace mipi_spi -} // namespace esphome +namespace esphome::mipi_spi { + +void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, + bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) { + ESP_LOGCONFIG(TAG, + "MIPI_SPI Display\n" + " Model: %s\n" + " Width: %d\n" + " Height: %d\n" + " Swap X/Y: %s\n" + " Mirror X: %s\n" + " Mirror Y: %s\n" + " Invert colors: %s\n" + " Color order: %s\n" + " Display pixels: %d bits\n" + " Endianness: %s\n" + " SPI Mode: %d\n" + " SPI Data rate: %uMHz\n" + " SPI Bus width: %d", + model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)), + YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB", + display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast(data_rate / 1000000), + bus_width); + LOG_PIN(" CS Pin: ", cs); + LOG_PIN(" Reset Pin: ", reset); + LOG_PIN(" DC Pin: ", dc); + if (offset_width != 0) + ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width); + if (offset_height != 0) + ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height); + if (brightness.has_value()) + ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value()); +} + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index fd5bc97596..083ff9507f 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -63,6 +63,11 @@ enum BusType { BUS_TYPE_SINGLE_16 = 16, // Single bit bus, but 16 bits per transfer }; +// Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro +void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, + bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width); + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -201,37 +206,9 @@ class MipiSpi : public display::Display, } void dump_config() override { - esph_log_config(TAG, - "MIPI_SPI Display\n" - " Model: %s\n" - " Width: %u\n" - " Height: %u", - this->model_, WIDTH, HEIGHT); - if constexpr (OFFSET_WIDTH != 0) - esph_log_config(TAG, " Offset width: %u", OFFSET_WIDTH); - if constexpr (OFFSET_HEIGHT != 0) - esph_log_config(TAG, " Offset height: %u", OFFSET_HEIGHT); - esph_log_config(TAG, - " Swap X/Y: %s\n" - " Mirror X: %s\n" - " Mirror Y: %s\n" - " Invert colors: %s\n" - " Color order: %s\n" - " Display pixels: %d bits\n" - " Endianness: %s\n", - YESNO(this->madctl_ & MADCTL_MV), YESNO(this->madctl_ & (MADCTL_MX | MADCTL_XFLIP)), - YESNO(this->madctl_ & (MADCTL_MY | MADCTL_YFLIP)), YESNO(this->invert_colors_), - this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little"); - if (this->brightness_.has_value()) - esph_log_config(TAG, " Brightness: %u", this->brightness_.value()); - log_pin(TAG, " CS Pin: ", this->cs_); - log_pin(TAG, " Reset Pin: ", this->reset_pin_); - log_pin(TAG, " DC Pin: ", this->dc_pin_); - esph_log_config(TAG, - " SPI Mode: %d\n" - " SPI Data rate: %dMHz\n" - " SPI Bus width: %d", - this->mode_, static_cast(this->data_rate_ / 1000000), BUS_TYPE); + internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_, + DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, + this->mode_, this->data_rate_, BUS_TYPE); } protected: diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index f53df5564c..fe153fedfa 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -4,7 +4,10 @@ from esphome import automation from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + include_builtin_idf_component, +) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -360,6 +363,8 @@ async def to_code(config): # This enables low-latency MQTT event processing instead of waiting for select() timeout if CORE.is_esp32: socket.require_wake_loop_threadsafe() + # Re-enable ESP-IDF's mqtt component (excluded by default to save compile time) + include_builtin_idf_component("mqtt") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index e7364f3406..a284b162dd 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -643,10 +643,34 @@ static bool topic_match(const char *message, const char *subscription) { } void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { - for (auto &subscription : this->subscriptions_) { - if (topic_match(topic.c_str(), subscription.topic.c_str())) - subscription.callback(topic, payload); - } +#ifdef USE_ESP8266 + // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. + // + // On ESP8266, this callback is invoked directly from the lwIP/AsyncTCP network stack + // which runs in the "sys" context with a very limited stack (~4KB). By the time we + // reach this function, the stack is already partially consumed by the network + // processing chain: tcp_input -> AsyncClient::_recv -> AsyncMqttClient::_onMessage -> here. + // + // MQTT subscription callbacks can trigger arbitrary user actions (automations, HTTP + // requests, sensor updates, etc.) which may have deep call stacks of their own. + // For example, an HTTP request action requires: DNS lookup -> TCP connect -> TLS + // handshake (if HTTPS) -> request formatting. This easily overflows the remaining + // system stack space, causing a LoadStoreAlignmentCause exception or silent corruption. + // + // By deferring to the main loop, we ensure callbacks execute with a fresh, full-size + // stack in the normal application context rather than the constrained network task. + // + // DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work + // in simple tests but will cause crashes with complex automations. + this->defer([this, topic, payload]() { +#endif + for (auto &subscription : this->subscriptions_) { + if (topic_match(topic.c_str(), subscription.topic.c_str())) + subscription.callback(topic, payload); + } +#ifdef USE_ESP8266 + }); +#endif } // Setters diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index c77217243c..104762c69e 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,7 +1,12 @@ from esphome import pins import esphome.codegen as cg from esphome.components import light -from esphome.components.esp32 import VARIANT_ESP32C3, VARIANT_ESP32S3, get_esp32_variant +from esphome.components.esp32 import ( + VARIANT_ESP32C3, + VARIANT_ESP32S3, + get_esp32_variant, + include_builtin_idf_component, +) import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -205,6 +210,10 @@ async def to_code(config): has_white = "W" in config[CONF_TYPE] method = config[CONF_METHOD] + # Re-enable ESP-IDF's RMT driver if using RMT method (excluded by default) + if CORE.is_esp32 and method[CONF_TYPE] == METHOD_ESP32_RMT: + include_builtin_idf_component("esp_driver_rmt") + method_template = METHODS[method[CONF_TYPE]].to_code( method, config[CONF_VARIANT], config[CONF_INVERT] ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index ffc509fc64..3bfcc95995 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -177,6 +177,8 @@ async def to_code(config): cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) if CORE.is_esp32: + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + esp32.include_builtin_idf_component("esp_http_client") esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 368b431d7b..b23da7799f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import mqtt, web_server +from esphome.components import mqtt, web_server, zigbee import esphome.config_validation as cv from esphome.const import ( CONF_ABOVE, @@ -189,6 +189,7 @@ validate_unit_of_measurement = cv.string_strict _NUMBER_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) + .extend(zigbee.NUMBER_SCHEMA) .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent), @@ -214,6 +215,7 @@ _NUMBER_SCHEMA = ( _NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number")) +_NUMBER_SCHEMA.add_extra(zigbee.validate_number) def number_schema( @@ -277,6 +279,8 @@ async def setup_number_core_( if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) + await zigbee.setup_number(var, config, min_value, max_value, step) + async def register_number( var, config, *, min_value: float, max_value: float, step: float diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index bb167033d1..114ecf435e 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -2,21 +2,20 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace pmsx003 { +namespace esphome::pmsx003 { static const char *const TAG = "pmsx003"; static const uint8_t START_CHARACTER_1 = 0x42; static const uint8_t START_CHARACTER_2 = 0x4D; -static const uint16_t PMS_STABILISING_MS = 30000; // time taken for the sensor to become stable after power on in ms +static const uint16_t STABILISING_MS = 30000; // time taken for the sensor to become stable after power on in ms -static const uint16_t PMS_CMD_MEASUREMENT_MODE_PASSIVE = - 0x0000; // use `PMS_CMD_MANUAL_MEASUREMENT` to trigger a measurement -static const uint16_t PMS_CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automatically perform measurements -static const uint16_t PMS_CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode -static const uint16_t PMS_CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode +static const uint16_t CMD_MEASUREMENT_MODE_PASSIVE = + 0x0000; // use `Command::MANUAL_MEASUREMENT` to trigger a measurement +static const uint16_t CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automatically perform measurements +static const uint16_t CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode +static const uint16_t CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode void PMSX003Component::setup() {} @@ -42,7 +41,7 @@ void PMSX003Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - if (this->update_interval_ <= PMS_STABILISING_MS) { + if (this->update_interval_ <= STABILISING_MS) { ESP_LOGCONFIG(TAG, " Mode: active continuous (sensor default)"); } else { ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles"); @@ -55,44 +54,44 @@ void PMSX003Component::loop() { const uint32_t now = App.get_loop_component_start_time(); // Initialize sensor mode on first loop - if (this->initialised_ == 0) { - if (this->update_interval_ > PMS_STABILISING_MS) { + if (!this->initialised_) { + if (this->update_interval_ > STABILISING_MS) { // Long update interval: use passive mode with sleep/wake cycles - this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); + this->send_command_(Command::MEASUREMENT_MODE, CMD_MEASUREMENT_MODE_PASSIVE); + this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_WAKEUP); } else { // Short/zero update interval: use active continuous mode - this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_ACTIVE); + this->send_command_(Command::MEASUREMENT_MODE, CMD_MEASUREMENT_MODE_ACTIVE); } - this->initialised_ = 1; + this->initialised_ = true; } // If we update less often than it takes the device to stabilise, spin the fan down // rather than running it constantly. It does take some time to stabilise, so we // need to keep track of what state we're in. - if (this->update_interval_ > PMS_STABILISING_MS) { + if (this->update_interval_ > STABILISING_MS) { switch (this->state_) { - case PMSX003_STATE_IDLE: + case State::IDLE: // Power on the sensor now so it'll be ready when we hit the update time - if (now - this->last_update_ < (this->update_interval_ - PMS_STABILISING_MS)) + if (now - this->last_update_ < (this->update_interval_ - STABILISING_MS)) return; - this->state_ = PMSX003_STATE_STABILISING; - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); + this->state_ = State::STABILISING; + this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_WAKEUP); this->fan_on_time_ = now; return; - case PMSX003_STATE_STABILISING: + case State::STABILISING: // wait for the sensor to be stable - if (now - this->fan_on_time_ < PMS_STABILISING_MS) + if (now - this->fan_on_time_ < STABILISING_MS) return; // consume any command responses that are in the serial buffer while (this->available()) this->read_byte(&this->data_[0]); // Trigger a new read - this->send_command_(PMS_CMD_MANUAL_MEASUREMENT, 0); - this->state_ = PMSX003_STATE_WAITING; + this->send_command_(Command::MANUAL_MEASUREMENT, 0); + this->state_ = State::WAITING; break; - case PMSX003_STATE_WAITING: + case State::WAITING: // Just go ahead and read stuff break; } @@ -180,27 +179,31 @@ optional PMSX003Component::check_byte_() { } bool PMSX003Component::check_payload_length_(uint16_t payload_length) { + // https://avaldebe.github.io/PyPMS/sensors/Plantower/ switch (this->type_) { - case PMSX003_TYPE_X003: - // The expected payload length is typically 28 bytes. - // However, a 20-byte payload check was already present in the code. - // No official documentation was found confirming this. - // Retaining this check to avoid breaking existing behavior. + case Type::PMS1003: + return payload_length == 28; // 2*13+2 + case Type::PMS3003: // Data 7/8/9 not set/reserved + return payload_length == 20; // 2*9+2 + case Type::PMSX003: // Data 13 not set/reserved + // Deprecated: Length 20 is for PMS3003 backwards compatibility return payload_length == 28 || payload_length == 20; // 2*13+2 - case PMSX003_TYPE_5003T: - case PMSX003_TYPE_5003S: - return payload_length == 28; // 2*13+2 (Data 13 not set/reserved) - case PMSX003_TYPE_5003ST: - return payload_length == 36; // 2*17+2 (Data 16 not set/reserved) + case Type::PMS5003S: + case Type::PMS5003T: // Data 13 not set/reserved + return payload_length == 28; // 2*13+2 + case Type::PMS5003ST: // Data 16 not set/reserved + return payload_length == 36; // 2*17+2 + case Type::PMS9003M: + return payload_length == 28; // 2*13+2 } return false; } -void PMSX003Component::send_command_(PMSX0003Command cmd, uint16_t data) { +void PMSX003Component::send_command_(Command cmd, uint16_t data) { uint8_t send_data[7] = { START_CHARACTER_1, // Start Byte 1 START_CHARACTER_2, // Start Byte 2 - cmd, // Command + static_cast(cmd), // Command uint8_t((data >> 8) & 0xFF), // Data 1 uint8_t((data >> 0) & 0xFF), // Data 2 0, // Verify Byte 1 @@ -265,7 +268,7 @@ void PMSX003Component::parse_data_() { if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); - if (this->type_ == PMSX003_TYPE_5003T) { + if (this->type_ == Type::PMS5003T) { ESP_LOGD(TAG, "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " "PM2.5 Particles %u Count/0.1L", @@ -289,7 +292,7 @@ void PMSX003Component::parse_data_() { } // Formaldehyde - if (this->type_ == PMSX003_TYPE_5003ST || this->type_ == PMSX003_TYPE_5003S) { + if (this->type_ == Type::PMS5003S || this->type_ == Type::PMS5003ST) { const uint16_t formaldehyde = this->get_16_bit_uint_(28); ESP_LOGD(TAG, "Got Formaldehyde: %u µg/m^3", formaldehyde); @@ -299,8 +302,8 @@ void PMSX003Component::parse_data_() { } // Temperature and Humidity - if (this->type_ == PMSX003_TYPE_5003ST || this->type_ == PMSX003_TYPE_5003T) { - const uint8_t temperature_offset = (this->type_ == PMSX003_TYPE_5003T) ? 24 : 30; + if (this->type_ == Type::PMS5003T || this->type_ == Type::PMS5003ST) { + const uint8_t temperature_offset = (this->type_ == Type::PMS5003T) ? 24 : 30; const float temperature = static_cast(this->get_16_bit_uint_(temperature_offset)) / 10.0f; const float humidity = this->get_16_bit_uint_(temperature_offset + 2) / 10.0f; @@ -314,22 +317,22 @@ void PMSX003Component::parse_data_() { } // Firmware Version and Error Code - if (this->type_ == PMSX003_TYPE_5003ST) { - const uint8_t firmware_version = this->data_[36]; - const uint8_t error_code = this->data_[37]; + if (this->type_ == Type::PMS1003 || this->type_ == Type::PMS5003ST || this->type_ == Type::PMS9003M) { + const uint8_t firmware_error_code_offset = (this->type_ == Type::PMS5003ST) ? 36 : 28; + const uint8_t firmware_version = this->data_[firmware_error_code_offset]; + const uint8_t error_code = this->data_[firmware_error_code_offset + 1]; ESP_LOGD(TAG, "Got Firmware Version: 0x%02X, Error Code: 0x%02X", firmware_version, error_code); } // Spin down the sensor again if we aren't going to need it until more time has // passed than it takes to stabilise - if (this->update_interval_ > PMS_STABILISING_MS) { - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_SLEEP); - this->state_ = PMSX003_STATE_IDLE; + if (this->update_interval_ > STABILISING_MS) { + this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_SLEEP); + this->state_ = State::IDLE; } this->status_clear_warning(); } -} // namespace pmsx003 -} // namespace esphome +} // namespace esphome::pmsx003 diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index f48121800e..d559f2dec0 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -5,27 +5,28 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace pmsx003 { +namespace esphome::pmsx003 { -enum PMSX0003Command : uint8_t { - PMS_CMD_MEASUREMENT_MODE = - 0xE1, // Data Options: `PMS_CMD_MEASUREMENT_MODE_PASSIVE`, `PMS_CMD_MEASUREMENT_MODE_ACTIVE` - PMS_CMD_MANUAL_MEASUREMENT = 0xE2, - PMS_CMD_SLEEP_MODE = 0xE4, // Data Options: `PMS_CMD_SLEEP_MODE_SLEEP`, `PMS_CMD_SLEEP_MODE_WAKEUP` +enum class Type : uint8_t { + PMS1003 = 0, + PMS3003, + PMSX003, // PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component) + PMS5003S, + PMS5003T, + PMS5003ST, + PMS9003M, }; -enum PMSX003Type { - PMSX003_TYPE_X003 = 0, - PMSX003_TYPE_5003T, - PMSX003_TYPE_5003ST, - PMSX003_TYPE_5003S, +enum class Command : uint8_t { + MEASUREMENT_MODE = 0xE1, // Data Options: `CMD_MEASUREMENT_MODE_PASSIVE`, `CMD_MEASUREMENT_MODE_ACTIVE` + MANUAL_MEASUREMENT = 0xE2, + SLEEP_MODE = 0xE4, // Data Options: `CMD_SLEEP_MODE_SLEEP`, `CMD_SLEEP_MODE_WAKEUP` }; -enum PMSX003State { - PMSX003_STATE_IDLE = 0, - PMSX003_STATE_STABILISING, - PMSX003_STATE_WAITING, +enum class State : uint8_t { + IDLE = 0, + STABILISING, + WAITING, }; class PMSX003Component : public uart::UARTDevice, public Component { @@ -37,7 +38,7 @@ class PMSX003Component : public uart::UARTDevice, public Component { void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } - void set_type(PMSX003Type type) { this->type_ = type; } + void set_type(Type type) { this->type_ = type; } void set_pm_1_0_std_sensor(sensor::Sensor *pm_1_0_std_sensor) { this->pm_1_0_std_sensor_ = pm_1_0_std_sensor; } void set_pm_2_5_std_sensor(sensor::Sensor *pm_2_5_std_sensor) { this->pm_2_5_std_sensor_ = pm_2_5_std_sensor; } @@ -77,20 +78,20 @@ class PMSX003Component : public uart::UARTDevice, public Component { optional check_byte_(); void parse_data_(); bool check_payload_length_(uint16_t payload_length); - void send_command_(PMSX0003Command cmd, uint16_t data); + void send_command_(Command cmd, uint16_t data); uint16_t get_16_bit_uint_(uint8_t start_index) const { return encode_uint16(this->data_[start_index], this->data_[start_index + 1]); } + Type type_; + State state_{State::IDLE}; + bool initialised_{false}; uint8_t data_[64]; uint8_t data_index_{0}; - uint8_t initialised_{0}; uint32_t fan_on_time_{0}; uint32_t last_update_{0}; uint32_t last_transmission_{0}; uint32_t update_interval_{0}; - PMSX003State state_{PMSX003_STATE_IDLE}; - PMSX003Type type_; // "Standard Particle" sensor::Sensor *pm_1_0_std_sensor_{nullptr}; @@ -118,5 +119,4 @@ class PMSX003Component : public uart::UARTDevice, public Component { sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace pmsx003 -} // namespace esphome +} // namespace esphome::pmsx003 diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index bebd3a01ee..cdcedc85ac 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -40,34 +40,128 @@ pmsx003_ns = cg.esphome_ns.namespace("pmsx003") PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component) PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor) -TYPE_PMSX003 = "PMSX003" +TYPE_PMS1003 = "PMS1003" +TYPE_PMS3003 = "PMS3003" +TYPE_PMSX003 = "PMSX003" # PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component) +TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" -TYPE_PMS5003S = "PMS5003S" +TYPE_PMS9003M = "PMS9003M" -PMSX003Type = pmsx003_ns.enum("PMSX003Type") +Type = pmsx003_ns.enum("Type", is_class=True) PMSX003_TYPES = { - TYPE_PMSX003: PMSX003Type.PMSX003_TYPE_X003, - TYPE_PMS5003T: PMSX003Type.PMSX003_TYPE_5003T, - TYPE_PMS5003ST: PMSX003Type.PMSX003_TYPE_5003ST, - TYPE_PMS5003S: PMSX003Type.PMSX003_TYPE_5003S, + TYPE_PMS1003: Type.PMS1003, + TYPE_PMS3003: Type.PMS3003, + TYPE_PMSX003: Type.PMSX003, + TYPE_PMS5003S: Type.PMS5003S, + TYPE_PMS5003T: Type.PMS5003T, + TYPE_PMS5003ST: Type.PMS5003ST, + TYPE_PMS9003M: Type.PMS9003M, } SENSORS_TO_TYPE = { - CONF_PM_1_0: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_2_5: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_10_0: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_1_0_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_2_5_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_10_0_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_0_3UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_0_5UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_1_0UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_2_5UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_5_0UM: [TYPE_PMSX003, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_PM_10_0UM: [TYPE_PMSX003, TYPE_PMS5003ST, TYPE_PMS5003S], - CONF_FORMALDEHYDE: [TYPE_PMS5003ST, TYPE_PMS5003S], + CONF_PM_1_0_STD: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_2_5_STD: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_10_0_STD: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_1_0: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_2_5: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_10_0: [ + TYPE_PMS1003, + TYPE_PMS3003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_0_3UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_0_5UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_1_0UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_2_5UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003T, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_5_0UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_PM_10_0UM: [ + TYPE_PMS1003, + TYPE_PMSX003, + TYPE_PMS5003S, + TYPE_PMS5003ST, + TYPE_PMS9003M, + ], + CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], CONF_HUMIDITY: [TYPE_PMS5003T, TYPE_PMS5003ST], } diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index f5d89f2f0f..b3dc213c5f 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -170,6 +170,9 @@ CONFIG_SCHEMA = remote_base.validate_triggers( async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32: + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + esp32.include_builtin_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_receive_symbols(config[CONF_RECEIVE_SYMBOLS])) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index f182a1ec0d..8383b9dd75 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -112,6 +112,9 @@ async def digital_write_action_to_code(config, action_id, template_arg, args): async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32: + # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) + esp32.include_builtin_idf_component("esp_driver_rmt") + var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_non_blocking(config[CONF_NON_BLOCKING])) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 18e1d9782c..10ee6d5212 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import audio, audio_dac import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_VOLUME +from esphome.const import CONF_AUDIO_DAC, CONF_DATA, CONF_ID, CONF_VOLUME from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -11,8 +11,6 @@ CODEOWNERS = ["@jesserockz", "@kahrendt"] IS_PLATFORM_COMPONENT = True -CONF_AUDIO_DAC = "audio_dac" - speaker_ns = cg.esphome_ns.namespace("speaker") Speaker = speaker_ns.class_("Speaker") diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index fd7b5fb03f..a6df61c053 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include namespace esphome { namespace tx20 { @@ -45,25 +45,25 @@ std::string Tx20Component::get_wind_cardinal_direction() const { return this->wi void Tx20Component::decode_and_publish_() { ESP_LOGVV(TAG, "Decode Tx20"); - std::string string_buffer; - std::string string_buffer_2; - std::vector bit_buffer; + std::array bit_buffer{}; + size_t bit_pos = 0; bool current_bit = true; + // Cap at MAX_BUFFER_SIZE - 1 to prevent out-of-bounds access (buffer_index can exceed MAX_BUFFER_SIZE in ISR) + const int max_buffer_index = + std::min(static_cast(this->store_.buffer_index), static_cast(MAX_BUFFER_SIZE - 1)); - for (int i = 1; i <= this->store_.buffer_index; i++) { - string_buffer_2 += to_string(this->store_.buffer[i]) + ", "; + for (int i = 1; i <= max_buffer_index; i++) { uint8_t repeat = this->store_.buffer[i] / TX20_BIT_TIME; // ignore segments at the end that were too short - string_buffer.append(repeat, current_bit ? '1' : '0'); - bit_buffer.insert(bit_buffer.end(), repeat, current_bit); + for (uint8_t j = 0; j < repeat && bit_pos < MAX_BUFFER_SIZE; j++) { + bit_buffer[bit_pos++] = current_bit; + } current_bit = !current_bit; } current_bit = !current_bit; - if (string_buffer.length() < MAX_BUFFER_SIZE) { - uint8_t remain = MAX_BUFFER_SIZE - string_buffer.length(); - string_buffer_2 += to_string(remain) + ", "; - string_buffer.append(remain, current_bit ? '1' : '0'); - bit_buffer.insert(bit_buffer.end(), remain, current_bit); + size_t bits_before_padding = bit_pos; + while (bit_pos < MAX_BUFFER_SIZE) { + bit_buffer[bit_pos++] = current_bit; } uint8_t tx20_sa = 0; @@ -108,8 +108,24 @@ void Tx20Component::decode_and_publish_() { // 2. Check received checksum matches calculated checksum // 3. Check that Wind Direction matches Wind Direction (Inverted) // 4. Check that Wind Speed matches Wind Speed (Inverted) - ESP_LOGVV(TAG, "BUFFER %s", string_buffer_2.c_str()); - ESP_LOGVV(TAG, "Decoded bits %s", string_buffer.c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + // Build debug strings from completed data + char debug_buf[320]; // buffer values: max 40 entries * 7 chars each + size_t debug_pos = 0; + for (int i = 1; i <= max_buffer_index; i++) { + debug_pos = buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%u, ", this->store_.buffer[i]); + } + if (bits_before_padding < MAX_BUFFER_SIZE) { + buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%zu, ", MAX_BUFFER_SIZE - bits_before_padding); + } + char bits_buf[MAX_BUFFER_SIZE + 1]; + for (size_t i = 0; i < MAX_BUFFER_SIZE; i++) { + bits_buf[i] = bit_buffer[i] ? '1' : '0'; + } + bits_buf[MAX_BUFFER_SIZE] = '\0'; + ESP_LOGVV(TAG, "BUFFER %s", debug_buf); + ESP_LOGVV(TAG, "Decoded bits %s", bits_buf); +#endif if (tx20_sa == 4) { if (chk == tx20_sd) { diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 9be196d420..8252e35023 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -12,8 +12,8 @@ from esphome.components.packet_transport import ( ) import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID -from esphome.core import ID, Lambda -from esphome.cpp_generator import ExpressionStatement, MockObj +from esphome.core import ID +from esphome.cpp_generator import literal CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["network"] @@ -24,6 +24,8 @@ udp_ns = cg.esphome_ns.namespace("udp") UDPComponent = udp_ns.class_("UDPComponent", cg.Component) UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action) trigger_args = cg.std_vector.template(cg.uint8) +trigger_argname = "data" +trigger_argtype = [(trigger_args, trigger_argname)] CONF_ADDRESSES = "addresses" CONF_LISTEN_ADDRESS = "listen_address" @@ -111,13 +113,14 @@ async def to_code(config): cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) if on_receive := config.get(CONF_ON_RECEIVE): on_receive = on_receive[0] - trigger = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID]) + trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID]) trigger = await automation.build_automation( - trigger, [(trigger_args, "data")], on_receive + trigger_id, trigger_argtype, on_receive ) - trigger = Lambda(str(ExpressionStatement(trigger.trigger(MockObj("data"))))) - trigger = await cg.process_lambda(trigger, [(trigger_args, "data")]) - cg.add(var.add_listener(trigger)) + trigger_lambda = await cg.process_lambda( + trigger.trigger(literal(trigger_argname)), trigger_argtype + ) + cg.add(var.add_listener(trigger_lambda)) cg.add(var.set_should_listen()) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 5c91150f30..224d6e3ab1 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -155,8 +155,9 @@ void USBCDCACMInstance::setup() { return; } - // Use a larger stack size for (very) verbose logging - const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE; + // Use a larger stack size for very verbose logging + constexpr size_t stack_size = + ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE; // Create a simple, unique task name per interface char task_name[] = "usb_tx_0"; diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 6c756575d4..6326b4d6ff 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -53,4 +53,4 @@ async def to_code(config): "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] ) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json - cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.5") + cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1b1da78308..c044148b32 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -24,7 +24,12 @@ from .const_zephyr import ( ZigbeeComponent, zigbee_ns, ) -from .zigbee_zephyr import zephyr_binary_sensor, zephyr_sensor, zephyr_switch +from .zigbee_zephyr import ( + zephyr_binary_sensor, + zephyr_number, + zephyr_sensor, + zephyr_switch, +) _LOGGER = logging.getLogger(__name__) @@ -43,6 +48,7 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType: BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) +NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -125,6 +131,21 @@ async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None: await zephyr_setup_switch(entity, config) +async def setup_number( + entity: cg.MockObj, + config: ConfigType, + min_value: float, + max_value: float, + step: float, +) -> None: + if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + return + if CORE.using_zephyr: + from .zigbee_zephyr import zephyr_setup_number + + await zephyr_setup_number(entity, config, min_value, max_value, step) + + def consume_endpoint(config: ConfigType) -> ConfigType: if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): return config @@ -152,6 +173,10 @@ def validate_switch(config: ConfigType) -> ConfigType: return consume_endpoint(config) +def validate_number(config: ConfigType) -> ConfigType: + return consume_endpoint(config) + + ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( cv.Schema( { diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 03c1bb546f..2d233755ac 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,6 +4,7 @@ zigbee_ns = cg.esphome_ns.namespace("zigbee") ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) BinaryAttrs = zigbee_ns.struct("BinaryAttrs") AnalogAttrs = zigbee_ns.struct("AnalogAttrs") +AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") CONF_MAX_EP_NUMBER = 8 CONF_ZIGBEE_ID = "zigbee_id" @@ -12,6 +13,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" +CONF_ZIGBEE_NUMBER = "zigbee_number" CONF_POWER_SOURCE = "power_source" POWER_SOURCE = { "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", @@ -38,3 +40,4 @@ ZB_ZCL_CLUSTER_ID_IDENTIFY = "ZB_ZCL_CLUSTER_ID_IDENTIFY" ZB_ZCL_CLUSTER_ID_BINARY_INPUT = "ZB_ZCL_CLUSTER_ID_BINARY_INPUT" ZB_ZCL_CLUSTER_ID_ANALOG_INPUT = "ZB_ZCL_CLUSTER_ID_ANALOG_INPUT" ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT = "ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT" +ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT = "ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT" diff --git a/esphome/components/zigbee/zigbee_number_zephyr.cpp b/esphome/components/zigbee/zigbee_number_zephyr.cpp new file mode 100644 index 0000000000..ceb318480c --- /dev/null +++ b/esphome/components/zigbee/zigbee_number_zephyr.cpp @@ -0,0 +1,111 @@ +#include "zigbee_number_zephyr.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_NUMBER) +#include "esphome/core/log.h" +extern "C" { +#include +#include +#include +#include +#include +} +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.number"; + +void ZigbeeNumber::setup() { + this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); + this->number_->add_on_state_callback([this](float state) { + this->cluster_attributes_->present_value = state; + ESP_LOGD(TAG, "Set attribute endpoint: %d, present_value %f", this->endpoint_, + this->cluster_attributes_->present_value); + ZB_ZCL_SET_ATTRIBUTE(this->endpoint_, ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE, + ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, (zb_uint8_t *) &cluster_attributes_->present_value, + ZB_FALSE); + this->parent_->force_report(); + }); +} + +void ZigbeeNumber::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee Number\n" + " Endpoint: %d, present_value %f", + this->endpoint_, this->cluster_attributes_->present_value); +} + +void ZigbeeNumber::zcl_device_cb_(zb_bufid_t bufid) { + zb_zcl_device_callback_param_t *p_device_cb_param = ZB_BUF_GET_PARAM(bufid, zb_zcl_device_callback_param_t); + zb_zcl_device_callback_id_t device_cb_id = p_device_cb_param->device_cb_id; + zb_uint16_t cluster_id = p_device_cb_param->cb_param.set_attr_value_param.cluster_id; + zb_uint16_t attr_id = p_device_cb_param->cb_param.set_attr_value_param.attr_id; + + switch (device_cb_id) { + /* ZCL set attribute value */ + case ZB_ZCL_SET_ATTR_VALUE_CB_ID: + if (cluster_id == ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT) { + ESP_LOGI(TAG, "Analog output attribute setting"); + if (attr_id == ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID) { + float value = + *reinterpret_cast(&p_device_cb_param->cb_param.set_attr_value_param.values.data32); + this->defer([this, value]() { + this->cluster_attributes_->present_value = value; + auto call = this->number_->make_call(); + call.set_value(value); + call.perform(); + }); + } + } else { + /* other clusters attribute handled here */ + ESP_LOGI(TAG, "Unhandled cluster attribute id: %d", cluster_id); + p_device_cb_param->status = RET_NOT_IMPLEMENTED; + } + break; + default: + p_device_cb_param->status = RET_NOT_IMPLEMENTED; + break; + } + + ESP_LOGD(TAG, "%s status: %hd", __func__, p_device_cb_param->status); +} + +const zb_uint8_t ZB_ZCL_ANALOG_OUTPUT_STATUS_FLAG_MAX_VALUE = 0x0F; + +static zb_ret_t check_value_analog_server(zb_uint16_t attr_id, zb_uint8_t endpoint, + zb_uint8_t *value) { // NOLINT(readability-non-const-parameter) + zb_ret_t ret = RET_OK; + ZVUNUSED(endpoint); + + switch (attr_id) { + case ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID: + ret = ZB_ZCL_CHECK_BOOL_VALUE(*value) ? RET_OK : RET_ERROR; + break; + case ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID: + break; + + case ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID: + if (*value > ZB_ZCL_ANALOG_OUTPUT_STATUS_FLAG_MAX_VALUE) { + ret = RET_ERROR; + } + break; + + default: + break; + } + + return ret; +} + +} // namespace esphome::zigbee + +void zb_zcl_analog_output_init_server() { + zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE, + esphome::zigbee::check_value_analog_server, (zb_zcl_cluster_write_attr_hook_t) NULL, + (zb_zcl_cluster_handler_t) NULL); +} + +void zb_zcl_analog_output_init_client() { + zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_CLIENT_ROLE, + (zb_zcl_cluster_check_value_t) NULL, (zb_zcl_cluster_write_attr_hook_t) NULL, + (zb_zcl_cluster_handler_t) NULL); +} + +#endif diff --git a/esphome/components/zigbee/zigbee_number_zephyr.h b/esphome/components/zigbee/zigbee_number_zephyr.h new file mode 100644 index 0000000000..aabb0392be --- /dev/null +++ b/esphome/components/zigbee/zigbee_number_zephyr.h @@ -0,0 +1,118 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_NUMBER) +#include "esphome/components/zigbee/zigbee_zephyr.h" +#include "esphome/core/component.h" +#include "esphome/components/number/number.h" +extern "C" { +#include +#include +} + +enum { + ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID = 0x001C, + ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID = 0x0041, + ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID = 0x0045, + ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID = 0x0051, + ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID = 0x0055, + ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID = 0x006A, + ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID = 0x006F, + ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID = 0x0075, +}; + +#define ZB_ZCL_ANALOG_OUTPUT_CLUSTER_REVISION_DEFAULT ((zb_uint16_t) 0x0001u) + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID, ZB_ZCL_ATTR_TYPE_CHAR_STRING, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \ + (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID, ZB_ZCL_ATTR_TYPE_BOOL, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } +// PresentValue +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \ + ZB_ZCL_ATTR_ACCESS_READ_WRITE | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } +// MaxPresentValue +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } +// MinPresentValue +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } +// Resolution +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID, ZB_ZCL_ATTR_TYPE_8BITMAP, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID, ZB_ZCL_ATTR_TYPE_16BIT_ENUM, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \ + (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \ + } + +#define ESPHOME_ZB_ZCL_DECLARE_ANALOG_OUTPUT_ATTRIB_LIST(attr_list, out_of_service, present_value, status_flag, \ + max_present_value, min_present_value, resolution, \ + engineering_units, description) \ + ZB_ZCL_START_DECLARE_ATTRIB_LIST_CLUSTER_REVISION(attr_list, ZB_ZCL_ANALOG_OUTPUT) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID, (out_of_service)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, (present_value)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID, (status_flag)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID, (max_present_value)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID, (min_present_value)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID, (resolution)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID, (engineering_units)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID, (description)) \ + ZB_ZCL_FINISH_DECLARE_ATTRIB_LIST + +void zb_zcl_analog_output_init_server(); +void zb_zcl_analog_output_init_client(); +#define ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT_SERVER_ROLE_INIT zb_zcl_analog_output_init_server +#define ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT_CLIENT_ROLE_INIT zb_zcl_analog_output_init_client + +namespace esphome::zigbee { + +class ZigbeeNumber : public ZigbeeEntity, public Component { + public: + ZigbeeNumber(number::Number *n) : number_(n) {} + void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) { + this->cluster_attributes_ = &cluster_attributes; + } + + void setup() override; + void dump_config() override; + + protected: + number::Number *number_; + AnalogAttrsOutput *cluster_attributes_{nullptr}; + void zcl_device_cb_(zb_bufid_t bufid); +}; + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.cpp b/esphome/components/zigbee/zigbee_switch_zephyr.cpp index fef02e5a0c..935140e9df 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_switch_zephyr.cpp @@ -50,7 +50,7 @@ void ZigbeeSwitch::zcl_device_cb_(zb_bufid_t bufid) { if (attr_id == ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID) { this->defer([this, value]() { this->cluster_attributes_->present_value = value ? ZB_TRUE : ZB_FALSE; - this->switch_->publish_state(value); + this->switch_->control(value); }); } } else { diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index eabf5c30d4..4763943e88 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -101,8 +101,8 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { zb_uint16_t attr_id = p_device_cb_param->cb_param.set_attr_value_param.attr_id; auto endpoint = p_device_cb_param->endpoint; - ESP_LOGI(TAG, "Zcl_device_cb %s id %hd, cluster_id %d, attr_id %d, endpoint: %d", __func__, device_cb_id, cluster_id, - attr_id, endpoint); + ESP_LOGI(TAG, "%s id %hd, cluster_id %d, attr_id %d, endpoint: %d", __func__, device_cb_id, cluster_id, attr_id, + endpoint); /* Set default response value. */ p_device_cb_param->status = RET_OK; diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index b75c192c1a..05895e8e61 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -60,6 +60,12 @@ struct AnalogAttrs { zb_uchar_t description[ZB_ZCL_MAX_STRING_SIZE]; }; +struct AnalogAttrsOutput : AnalogAttrs { + float max_present_value; + float min_present_value; + float resolution; +}; + class ZigbeeComponent : public Component { public: void setup() override; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index b3bd10bfab..0b6daa9476 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -55,6 +55,7 @@ from .const_zephyr import ( CONF_WIPE_ON_BOOT, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, + CONF_ZIGBEE_NUMBER, CONF_ZIGBEE_SENSOR, CONF_ZIGBEE_SWITCH, KEY_EP_NUMBER, @@ -62,12 +63,14 @@ from .const_zephyr import ( POWER_SOURCE, ZB_ZCL_BASIC_ATTRS_EXT_T, ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_ID_BASIC, ZB_ZCL_CLUSTER_ID_BINARY_INPUT, ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_ID_IDENTIFY, ZB_ZCL_IDENTIFY_ATTRS_T, AnalogAttrs, + AnalogAttrsOutput, BinaryAttrs, ZigbeeComponent, zigbee_ns, @@ -76,6 +79,7 @@ from .const_zephyr import ( ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component) ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component) +ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component) # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py @@ -139,6 +143,15 @@ zephyr_switch = cv.Schema( } ) +zephyr_number = cv.Schema( + { + cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), + cv.OnlyWith(CONF_ZIGBEE_NUMBER, ["nrf52", "zigbee"]): cv.declare_id( + ZigbeeNumber + ), + } +) + async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("ZIGBEE", True) @@ -344,6 +357,16 @@ async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None: CORE.add_job(_add_switch, entity, config) +async def zephyr_setup_number( + entity: cg.MockObj, + config: ConfigType, + min_value: float, + max_value: float, + step: float, +) -> None: + CORE.add_job(_add_number, entity, config, min_value, max_value, step) + + def get_slot_index() -> int: """Find the next available endpoint slot.""" slot = next( @@ -451,3 +474,31 @@ async def _add_switch(entity: cg.MockObj, config: ConfigType) -> None: ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, "ZB_HA_CUSTOM_ATTR_DEVICE_ID", ) + + +async def _add_number( + entity: cg.MockObj, + config: ConfigType, + min_value: float, + max_value: float, + step: float, +) -> None: + # Get BACnet engineering unit from unit_of_measurement + unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") + bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + + await _add_zigbee_ep( + entity, + config, + CONF_ZIGBEE_NUMBER, + AnalogAttrsOutput, + "ESPHOME_ZB_ZCL_DECLARE_ANALOG_OUTPUT_ATTRIB_LIST", + ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, + "ZB_HA_CUSTOM_ATTR_DEVICE_ID", + extra_field_values={ + "max_present_value": max_value, + "min_present_value": min_value, + "resolution": step, + "engineering_units": bacnet_unit, + }, + ) diff --git a/esphome/const.py b/esphome/const.py index 4243b2e25d..4bf47b8f83 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -149,6 +149,7 @@ CONF_ASSUMED_STATE = "assumed_state" CONF_AT = "at" CONF_ATTENUATION = "attenuation" CONF_ATTRIBUTE = "attribute" +CONF_AUDIO_DAC = "audio_dac" CONF_AUTH = "auth" CONF_AUTO_CLEAR_ENABLED = "auto_clear_enabled" CONF_AUTO_MODE = "auto_mode" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 9a7dd49609..5308ad241e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -278,9 +278,13 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): + from esphome.cpp_generator import Expression, statement + # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value + elif isinstance(value, Expression): + self._value = str(statement(value)) else: self._value = value self._parts = None diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 55eb25ce09..0e77be9ee4 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -210,7 +210,7 @@ void Application::loop() { #ifdef USE_ESP32 esp_chip_info_t chip_info; esp_chip_info(&chip_info); - ESP_LOGI(TAG, "ESP32 Chip: %s r%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, + ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) // Suggest optimization for chips that don't need the PSRAM cache workaround diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cff0748c95..83f2d6cf81 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -462,6 +462,16 @@ def statement(expression: Expression | Statement) -> Statement: return ExpressionStatement(expression) +def literal(name: str) -> "MockObj": + """Create a literal name that will appear in the generated code + not surrounded by quotes. + + :param name: The name of the literal. + :return: The literal as a MockObj. + """ + return MockObj(name, "") + + def variable( id_: ID, rhs: SafeExpType, type_: "MockObj" = None, register=True ) -> "MockObj": @@ -665,7 +675,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: async def process_lambda( - value: Lambda, + value: Lambda | Expression, parameters: TemplateArgsType, capture: str = "", return_type: SafeExpType = None, @@ -689,6 +699,14 @@ async def process_lambda( if value is None: return None + # Inadvertently passing a malformed parameters value will lead to the build process mysteriously hanging at the + # "Generating C++ source..." stage, so check here to save the developer's hair. + assert isinstance(parameters, list) and all( + isinstance(p, tuple) and len(p) == 2 for p in parameters + ) + if isinstance(value, Expression): + value = Lambda(value) + parts = value.parts[:] for i, id in enumerate(value.requires_ids): full_id, var = await get_variable_with_full_id(id) diff --git a/platformio.ini b/platformio.ini index 0f5bf2f8fb..bb0de3c2b1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -114,7 +114,7 @@ lib_deps = ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp - ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base + ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base makuna/NeoPixelBus@2.7.3 ; neopixelbus ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) @@ -202,7 +202,7 @@ lib_deps = ${common:arduino.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json - ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base + ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base build_flags = ${common:arduino.build_flags} -DUSE_RP2040 @@ -218,7 +218,7 @@ framework = arduino lib_compat_mode = soft lib_deps = bblanchon/ArduinoJson@7.4.2 ; json - ESP32Async/ESPAsyncWebServer@3.9.5 ; web_server_base + ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.2 ; wireguard build_flags = ${common:arduino.build_flags} diff --git a/tests/components/ch423/common.yaml b/tests/components/ch423/common.yaml new file mode 100644 index 0000000000..ccf9170bd0 --- /dev/null +++ b/tests/components/ch423/common.yaml @@ -0,0 +1,36 @@ +ch423: + - id: ch423_hub + i2c_id: i2c_bus + +binary_sensor: + - platform: gpio + id: ch423_input + name: CH423 Binary Sensor + pin: + ch423: ch423_hub + number: 1 + mode: INPUT + inverted: true + - platform: gpio + id: ch423_input_2 + name: CH423 Binary Sensor 2 + pin: + ch423: ch423_hub + number: 0 + mode: INPUT + inverted: false +output: + - platform: gpio + id: ch423_out_11 + pin: + ch423: ch423_hub + number: 11 + mode: OUTPUT_OPEN_DRAIN + inverted: true + - platform: gpio + id: ch423_out_23 + pin: + ch423: ch423_hub + number: 23 + mode: OUTPUT_OPEN_DRAIN + inverted: false diff --git a/tests/components/ch423/test.esp32-idf.yaml b/tests/components/ch423/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/ch423/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ch423/test.esp8266-ard.yaml b/tests/components/ch423/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/ch423/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ch423/test.rp2040-ard.yaml b/tests/components/ch423/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/ch423/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-generic.yaml b/tests/components/dlms_meter/common-generic.yaml new file mode 100644 index 0000000000..edb1c66f0f --- /dev/null +++ b/tests/components/dlms_meter/common-generic.yaml @@ -0,0 +1,11 @@ +dlms_meter: + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! + +sensor: + - platform: dlms_meter + reactive_energy_plus: + name: "Reactive energy taken from grid" + reactive_energy_minus: + name: "Reactive energy put into grid" + +<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-netznoe.yaml b/tests/components/dlms_meter/common-netznoe.yaml new file mode 100644 index 0000000000..db064b64f9 --- /dev/null +++ b/tests/components/dlms_meter/common-netznoe.yaml @@ -0,0 +1,17 @@ +dlms_meter: + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! + provider: netznoe # (optional) key - only set if using evn + +sensor: + - platform: dlms_meter + # EVN + power_factor: + name: "Power Factor" + +text_sensor: + - platform: dlms_meter + # EVN + meternumber: + name: "meterNumber" + +<<: !include common.yaml diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml new file mode 100644 index 0000000000..6aa4e1b0ff --- /dev/null +++ b/tests/components/dlms_meter/common.yaml @@ -0,0 +1,27 @@ +sensor: + - platform: dlms_meter + voltage_l1: + name: "Voltage L1" + voltage_l2: + name: "Voltage L2" + voltage_l3: + name: "Voltage L3" + current_l1: + name: "Current L1" + current_l2: + name: "Current L2" + current_l3: + name: "Current L3" + active_power_plus: + name: "Active power taken from grid" + active_power_minus: + name: "Active power put into grid" + active_energy_plus: + name: "Active energy taken from grid" + active_energy_minus: + name: "Active energy put into grid" + +text_sensor: + - platform: dlms_meter + timestamp: + name: "timestamp" diff --git a/tests/components/dlms_meter/test.esp32-ard.yaml b/tests/components/dlms_meter/test.esp32-ard.yaml new file mode 100644 index 0000000000..4f8a06c31b --- /dev/null +++ b/tests/components/dlms_meter/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml + +<<: !include common-generic.yaml diff --git a/tests/components/dlms_meter/test.esp32-idf.yaml b/tests/components/dlms_meter/test.esp32-idf.yaml new file mode 100644 index 0000000000..f993515fce --- /dev/null +++ b/tests/components/dlms_meter/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml + +<<: !include common-netznoe.yaml diff --git a/tests/components/dlms_meter/test.esp8266-ard.yaml b/tests/components/dlms_meter/test.esp8266-ard.yaml new file mode 100644 index 0000000000..2ce7955c9f --- /dev/null +++ b/tests/components/dlms_meter/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml + +<<: !include common-generic.yaml diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index d38cdfe2fd..f80c854de5 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -8,6 +8,16 @@ esp32: enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization use_full_certificate_bundle: false # Test CMN bundle (default) + include_builtin_idf_components: + - freertos # Test escape hatch (freertos is always included anyway) + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true wifi: ssid: MySSID diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index 00a4ceec27..d67787b3d5 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -10,6 +10,14 @@ esp32: ref: 2.7.0 advanced: enable_idf_experimental_features: yes + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true ota: platform: esphome diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index 4ae5e6b999..7a3bbe55b3 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -5,6 +5,14 @@ esp32: advanced: execute_from_psram: true disable_libc_locks_in_iram: true # Test default RAM optimization enabled + disable_debug_stubs: true + disable_ocd_aware: true + disable_usb_serial_jtag_secondary: true + disable_dev_null_vfs: true + disable_mbedtls_peer_cert: true + disable_mbedtls_pkcs7: true + disable_regi2c_in_iram: true + disable_fatfs: true psram: mode: octal diff --git a/tests/components/mipi_spi/test.esp8266-ard.yaml b/tests/components/mipi_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ef6197d852 --- /dev/null +++ b/tests/components/mipi_spi/test.esp8266-ard.yaml @@ -0,0 +1,10 @@ +substitutions: + dc_pin: GPIO15 + cs_pin: GPIO5 + enable_pin: GPIO4 + reset_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pmsx003/common.yaml b/tests/components/pmsx003/common.yaml index 3c60995804..eaa3cbc3e9 100644 --- a/tests/components/pmsx003/common.yaml +++ b/tests/components/pmsx003/common.yaml @@ -8,11 +8,11 @@ sensor: pm_10_0: name: PM 10.0 Concentration pm_1_0_std: - name: PM 1.0 Standard Atmospher Concentration + name: PM 1.0 Standard Atmospheric Concentration pm_2_5_std: - name: PM 2.5 Standard Atmospher Concentration + name: PM 2.5 Standard Atmospheric Concentration pm_10_0_std: - name: PM 10.0 Standard Atmospher Concentration + name: PM 10.0 Standard Atmospheric Concentration pm_0_3um: name: Particulate Count >0.3um pm_0_5um: diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index e4dee5f74a..2af35ff148 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -6,8 +6,6 @@ binary_sensor: name: "Garage Door Open 2" - platform: template name: "Garage Door Open 3" - - platform: template - name: "Garage Door Open 4" - platform: template name: "Garage Door Internal" internal: True @@ -44,3 +42,11 @@ switch: - platform: template name: "Template Switch" optimistic: true + +number: + - platform: template + name: "Template number" + optimistic: true + min_value: 2 + max_value: 100 + step: 1 diff --git a/tests/test_build_components/common/uart_2400/esp32-ard.yaml b/tests/test_build_components/common/uart_2400/esp32-ard.yaml new file mode 100644 index 0000000000..e0b6571104 --- /dev/null +++ b/tests/test_build_components/common/uart_2400/esp32-ard.yaml @@ -0,0 +1,11 @@ +# Common UART configuration for ESP32 Arduino tests - 2400 baud + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 diff --git a/tests/test_build_components/common/uart_2400/esp32-idf.yaml b/tests/test_build_components/common/uart_2400/esp32-idf.yaml new file mode 100644 index 0000000000..7bded8c91d --- /dev/null +++ b/tests/test_build_components/common/uart_2400/esp32-idf.yaml @@ -0,0 +1,11 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 diff --git a/tests/test_build_components/common/uart_2400/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400/esp8266-ard.yaml new file mode 100644 index 0000000000..6c9a4a558d --- /dev/null +++ b/tests/test_build_components/common/uart_2400/esp8266-ard.yaml @@ -0,0 +1,11 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 diff --git a/tests/unit_tests/components/test_ch423.py b/tests/unit_tests/components/test_ch423.py new file mode 100644 index 0000000000..ac79fe48fe --- /dev/null +++ b/tests/unit_tests/components/test_ch423.py @@ -0,0 +1,58 @@ +"""Tests for ch423 component validation.""" + +from unittest.mock import patch + +from esphome import config, yaml_util +from esphome.core import CORE + + +def test_ch423_mixed_gpio_modes_fails(tmp_path, capsys): + """Test that mixing input/output on GPIO pins 0-7 fails validation.""" + test_file = tmp_path / "test.yaml" + test_file.write_text(""" +esphome: + name: test + +esp8266: + board: esp01_1m + +i2c: + sda: GPIO4 + scl: GPIO5 + +ch423: + - id: ch423_hub + +binary_sensor: + - platform: gpio + name: "CH423 Input 0" + pin: + ch423: ch423_hub + number: 0 + mode: input + +switch: + - platform: gpio + name: "CH423 Output 1" + pin: + ch423: ch423_hub + number: 1 + mode: output +""") + + parsed_yaml = yaml_util.load_yaml(test_file) + + with ( + patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), + patch.object(CORE, "config_path", test_file), + ): + result = config.read_config({}) + + assert result is None, "Expected validation to fail with mixed GPIO modes" + + # Check that the error message mentions the GPIO pin restriction + captured = capsys.readouterr() + assert ( + "GPIO pins (0-7) must all be configured as input or all as output" + in captured.out + ) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 2c9f760c8e..8755e6e2a1 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -347,3 +347,280 @@ class TestMockObj: assert isinstance(actual, cg.MockObj) assert actual.base == "foo.eek" assert actual.op == "." + + +class TestStatementFunction: + """Tests for the statement() function.""" + + def test_statement__expression_converted_to_statement(self): + """Test that expressions are converted to ExpressionStatement.""" + expr = cg.RawExpression("foo()") + result = cg.statement(expr) + + assert isinstance(result, cg.ExpressionStatement) + assert str(result) == "foo();" + + def test_statement__statement_unchanged(self): + """Test that statements are returned unchanged.""" + stmt = cg.RawStatement("foo()") + result = cg.statement(stmt) + + assert result is stmt + assert str(result) == "foo()" + + def test_statement__expression_statement_unchanged(self): + """Test that ExpressionStatement is returned unchanged.""" + stmt = cg.ExpressionStatement(42) + result = cg.statement(stmt) + + assert result is stmt + assert str(result) == "42;" + + def test_statement__line_comment_unchanged(self): + """Test that LineComment is returned unchanged.""" + stmt = cg.LineComment("This is a comment") + result = cg.statement(stmt) + + assert result is stmt + assert str(result) == "// This is a comment" + + +class TestLiteralFunction: + """Tests for the literal() function.""" + + def test_literal__creates_mockobj(self): + """Test that literal() creates a MockObj.""" + result = cg.literal("MY_CONSTANT") + + assert isinstance(result, cg.MockObj) + assert result.base == "MY_CONSTANT" + assert result.op == "" + + def test_literal__string_representation(self): + """Test that literal names appear unquoted in generated code.""" + result = cg.literal("nullptr") + + assert str(result) == "nullptr" + + def test_literal__can_be_used_in_expressions(self): + """Test that literals can be used as part of larger expressions.""" + null_lit = cg.literal("nullptr") + expr = cg.CallExpression(cg.RawExpression("my_func"), null_lit) + + assert str(expr) == "my_func(nullptr)" + + def test_literal__common_cpp_literals(self): + """Test common C++ literal values.""" + test_cases = [ + ("nullptr", "nullptr"), + ("true", "true"), + ("false", "false"), + ("NULL", "NULL"), + ("NAN", "NAN"), + ] + + for name, expected in test_cases: + result = cg.literal(name) + assert str(result) == expected + + +class TestLambdaConstructor: + """Tests for the Lambda class constructor in core/__init__.py.""" + + def test_lambda__from_string(self): + """Test Lambda constructor with string argument.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x + 1;") + + assert lambda_obj.value == "return x + 1;" + assert str(lambda_obj) == "return x + 1;" + + def test_lambda__from_expression(self): + """Test Lambda constructor with Expression argument.""" + from esphome.core import Lambda + + expr = cg.RawExpression("x + 1") + lambda_obj = Lambda(expr) + + # Expression should be converted to statement (with semicolon) + assert lambda_obj.value == "x + 1;" + + def test_lambda__from_lambda(self): + """Test Lambda constructor with another Lambda argument.""" + from esphome.core import Lambda + + original = Lambda("return x + 1;") + copy = Lambda(original) + + assert copy.value == original.value + assert copy.value == "return x + 1;" + + def test_lambda__parts_parsing(self): + """Test that Lambda correctly parses parts with id() references.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return id(my_sensor).state;") + parts = lambda_obj.parts + + # Parts should be split by LAMBDA_PROG regex: text, id, op, text + assert len(parts) == 4 + assert parts[0] == "return " + assert parts[1] == "my_sensor" + assert parts[2] == "." + assert parts[3] == "state;" + + def test_lambda__requires_ids(self): + """Test that Lambda correctly extracts required IDs.""" + from esphome.core import ID, Lambda + + lambda_obj = Lambda("return id(sensor1).state + id(sensor2).value;") + ids = lambda_obj.requires_ids + + assert len(ids) == 2 + assert all(isinstance(id_obj, ID) for id_obj in ids) + assert ids[0].id == "sensor1" + assert ids[1].id == "sensor2" + + def test_lambda__no_ids(self): + """Test Lambda with no id() references.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return 42;") + ids = lambda_obj.requires_ids + + assert len(ids) == 0 + + def test_lambda__comment_removal(self): + """Test that comments are removed when parsing parts.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return id(sensor).state; // Get sensor state") + parts = lambda_obj.parts + + # Comment should be replaced with space, not affect parsing + assert "my_sensor" not in str(parts) + + def test_lambda__multiline_string(self): + """Test Lambda with multiline string.""" + from esphome.core import Lambda + + code = """if (id(sensor).state > 0) { + return true; +} +return false;""" + lambda_obj = Lambda(code) + + assert lambda_obj.value == code + assert "sensor" in [id_obj.id for id_obj in lambda_obj.requires_ids] + + +@pytest.mark.asyncio +class TestProcessLambda: + """Tests for the process_lambda() async function.""" + + async def test_process_lambda__none_value(self): + """Test that None returns None.""" + result = await cg.process_lambda(None, []) + + assert result is None + + async def test_process_lambda__with_expression(self): + """Test process_lambda with Expression argument.""" + + expr = cg.RawExpression("return x + 1") + result = await cg.process_lambda(expr, [(int, "x")]) + + assert isinstance(result, cg.LambdaExpression) + assert "x + 1" in str(result) + + async def test_process_lambda__simple_lambda_no_ids(self): + """Test process_lambda with simple Lambda without id() references.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x + 1;") + result = await cg.process_lambda(lambda_obj, [(int, "x")]) + + assert isinstance(result, cg.LambdaExpression) + # Should have parameter + lambda_str = str(result) + assert "int32_t x" in lambda_str + assert "return x + 1;" in lambda_str + + async def test_process_lambda__with_return_type(self): + """Test process_lambda with return type specified.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x > 0;") + result = await cg.process_lambda(lambda_obj, [(int, "x")], return_type=bool) + + assert isinstance(result, cg.LambdaExpression) + lambda_str = str(result) + assert "-> bool" in lambda_str + + async def test_process_lambda__with_capture(self): + """Test process_lambda with capture specified.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return captured + x;") + result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="captured") + + assert isinstance(result, cg.LambdaExpression) + lambda_str = str(result) + assert "[captured]" in lambda_str + + async def test_process_lambda__empty_capture(self): + """Test process_lambda with empty capture (stateless lambda).""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x + 1;") + result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="") + + assert isinstance(result, cg.LambdaExpression) + lambda_str = str(result) + assert "[]" in lambda_str + + async def test_process_lambda__no_parameters(self): + """Test process_lambda with no parameters.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return 42;") + result = await cg.process_lambda(lambda_obj, []) + + assert isinstance(result, cg.LambdaExpression) + lambda_str = str(result) + # Should have empty parameter list + assert "()" in lambda_str + + async def test_process_lambda__multiple_parameters(self): + """Test process_lambda with multiple parameters.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x + y + z;") + result = await cg.process_lambda( + lambda_obj, [(int, "x"), (float, "y"), (bool, "z")] + ) + + assert isinstance(result, cg.LambdaExpression) + lambda_str = str(result) + assert "int32_t x" in lambda_str + assert "float y" in lambda_str + assert "bool z" in lambda_str + + async def test_process_lambda__parameter_validation(self): + """Test that malformed parameters raise assertion error.""" + from esphome.core import Lambda + + lambda_obj = Lambda("return x;") + + # Test invalid parameter format (not list of tuples) + with pytest.raises(AssertionError): + await cg.process_lambda(lambda_obj, "invalid") + + # Test invalid tuple format (not 2-element tuples) + with pytest.raises(AssertionError): + await cg.process_lambda(lambda_obj, [(int, "x", "extra")]) + + # Test invalid tuple format (single element) + with pytest.raises(AssertionError): + await cg.process_lambda(lambda_obj, [(int,)])