Compare commits

...
Author SHA1 Message Date
J. Nick Koston 9740fd3c36 Merge branch 'dev' into platformio-skip-private-probe 2026-08-30 19:31:25 -05:00
J. Nick Koston fbe306f00b [homeassistant] Add integration test for binary sensor initial state triggers (#18894) 2026-08-30 19:30:40 -05:00
J. Nick Koston fc977b7b5e [ci] Cache the integration test PlatformIO dir (#18896) 2026-08-30 17:34:32 -05:00
J. Nick Koston 2f1d3f8299 [core] Deduplicate the host program path lookup (#18897) 2026-08-30 17:34:16 -05:00
Bonne Eggleston cb54e57d84 [modbus] Yield the whole-millisecond part of the interframe wait (#18898) 2026-08-30 16:43:28 -05:00
Brandon HarveyandJ. Nick Koston fb65096ea3 [api] Add description and example metadata to user-defined actions (#18881)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-30 13:52:51 -05:00
esphome[bot] 0607c228f5 Bump aioesphomeapi from 46.2.1 to 46.3.0 (#18892) 2026-08-30 17:25:29 +00:00
Clyde Stubbs b9eda644cd [lvgl] Add radial and conical gradients (#18818) 2026-08-30 21:21:07 +10:00
Bonne Eggleston a811aa840c [modbus] Speed up the bus with microsecond-accurate timing (#12421) 2026-08-30 03:14:12 -05:00
J. Nick Koston cd28a8a03e [internal_temperature] Re-include esp_phy on the original ESP32 so the PHY blob links (#18884) 2026-08-29 23:25:25 -05:00
J. Nick Koston 6957576867 [esp32] Skip full rebuild on sdkconfig change with the esp-idf toolchain (#18876) 2026-08-29 03:09:12 +00:00
J. Nick Koston 3fea080ed8 [core] Hash downloaded file paths at the default data dir location (#18824) 2026-08-28 21:08:15 -05:00
Clyde Stubbs 06bc3d70c2 [mipi_rgb] Add Elecrow Crowpanel Advance 7 (#18810) 2026-08-28 22:02:27 -04:00
J. Nick Koston 2576a0e340 [ci] Drop picolibc from the cached ESP-IDF toolchains (#18871) 2026-08-29 01:50:24 +00:00
J. Nick Koston ce163b8258 [ci] Cache clang-tidy idedata and key ESP-IDF cache on Python version (#18868) 2026-08-28 20:41:57 -05:00
Bonne Eggleston bcec1d6cb8 [modbus] Decode by register address in the modbus sensor components (#18874) 2026-08-28 18:44:37 -05:00
Bonne Eggleston 1c44cec343 [modbus] Add value_at() and decode registers without the byte round-trip (#18873) 2026-08-28 17:44:00 -05:00
Clyde Stubbs c9848d8fa6 [light] Fix gamma table dead zone collapsing to 0 (#18845) 2026-08-29 08:37:20 +10:00
Frédéric Metrichdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude Opus 4.5pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>CopilotJ. Nick Koston
0dc0cf83de [mk2pvrouter] Add Mk2PVRouter component with sensor support (#8487)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-08-28 21:02:32 +00:00
dependabot[bot] ed3429d372 Bump resvg-py from 0.4.0 to 0.5.0 (#18864)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 15:43:52 -05:00
Bonne EgglestonandJ. Nick Koston dfac9e1f11 [pzemac] Use the typed modbus read callback with address-based extraction (#18855)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:35:10 -05:00
Bonne EgglestonandJ. Nick Koston 7255315ce2 [sdm_meter] Use the typed modbus read callback with address-based extraction (#18849)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:34:23 -05:00
Bonne EgglestonandJ. Nick Koston 346c250901 [selec_meter] Use the typed modbus read callback with address-based extraction (#18854)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:34:03 -05:00
Bonne EgglestonandJ. Nick Koston 5fc9bff371 [pzemdc] Use the typed modbus read callback with address-based extraction (#18853)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:27:45 -05:00
Bonne EgglestonandJ. Nick Koston 97f643574c [kuntze] Use the typed modbus read callback and queue all reads at once (#18852)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:27:01 -05:00
Bonne EgglestonandJ. Nick Koston e8d76b735b [havells_solar] Use the typed modbus read callback with address-based extraction (#18851)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 15:21:52 -05:00
J. Nick Koston 03147bc3b1 [core] Avoid double promotion in update interval and step formatting (#18825) 2026-08-28 15:12:50 -05:00
Bonne Eggleston d4348335dd [growatt_solar] Use the typed modbus read callback and drop the send-pacing state machine (#18850) 2026-08-28 15:11:42 -05:00
Bonne Eggleston 246670e22b [modbus] Add a compile-time register value decoder (#18863) 2026-08-28 14:54:31 -05:00
J. Nick Koston 0dce7f4845 [esp8266] Add the native library backend (#18558) 2026-08-28 13:50:52 -05:00
J. Nick Koston 1623fe0852 [esp32] Exclude the WiFi and Bluetooth stacks from builds that do not use them (#18599) 2026-08-28 18:49:21 +00:00
J. Nick Koston 4333870590 [core] Use uv for the ESP-IDF Python environment when available (#18838) 2026-08-28 13:43:48 -05:00
J. Nick Koston cc41fd0beb [core] Make rmtree tolerate missing paths and concurrent directory changes (#18846) 2026-08-28 13:42:42 -05:00
Bonne EgglestonandJ. Nick Koston 59397b4e28 [modbus] Build single-value register writes on a right-sized stack buffer (#18844)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 18:36:24 +00:00
Bonne Eggleston 768ab5b672 [modbus] Hub and helpers cleanup; tighten queue_pdu validation (#18847) 2026-08-28 13:16:41 -05:00
J. Nick Koston 8db07d0de5 [mdns] Bump espressif/mdns to 1.12.0 (#18861) 2026-08-28 12:57:45 -05:00
a1515ec662 [modbus_controller] Replace register_count/force_new_range with reuse_previous_range (#18085)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-28 11:54:06 -05:00
J. Nick Koston 8a4d584906 Trim docstring 2026-08-27 10:55:57 -05:00
J. Nick Koston 7bbb1ff685 Trim comment 2026-08-27 10:55:17 -05:00
J. Nick Koston e829d5cc4b Simplify: drop unneeded patch guard, build the client eagerly, slim tests 2026-08-27 10:53:34 -05:00
J. Nick Koston 9270f3fd34 Address review: patch the prefetch subprocess, scope the in-process override to our client, isolate tests 2026-08-27 10:49:12 -05:00
J. Nick Koston 6a56dffd28 Trim comments 2026-08-27 10:39:50 -05:00
J. Nick Koston 61c0f84ed2 [core] Skip PlatformIO's private-package authorization probe 2026-08-27 10:35:55 -05:00
148 changed files with 6761 additions and 1356 deletions
@@ -0,0 +1,50 @@
name: Cache clang-tidy idedata
description: >
Cache the clang-tidy idedata and the headers it references under .temp
(headers only, about 30MB per env). Run after restore-python and cache-esp-idf.
inputs:
environment:
description: 'clang-tidy environment (e.g. esp32-idf-tidy).'
required: true
runs:
using: composite
steps:
- name: Compute cache key
id: key
shell: bash
run: |
. venv/bin/activate
[ -n "${{ inputs.environment }}" ] || { echo "::error::cache-clang-tidy-idedata: 'environment' input is empty"; exit 1; }
hash=$(python -c 'import sys; sys.path.insert(0, "script"); from clang_tidy_hash import idedata_cache_hash; print(idedata_cache_hash("${{ inputs.environment }}"))')
pyver=$(python -c 'import platform; print(platform.python_version())')
# Generating idedata is what installs ESP-IDF; never skip it over a missing
# install. This also skips the save, so a dev run that installs ESP-IDF
# warms the idedata cache on the next run.
if [ -d ~/.esphome-idf/frameworks ]; then
echo "skip=false" >> "$GITHUB_OUTPUT"
else
echo "ESP-IDF install missing, not using the clang-tidy idedata cache"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
echo "key=${{ runner.os }}-tidy-idedata-${{ inputs.environment }}-$hash-py$pyver" >> "$GITHUB_OUTPUT"
{
echo "path<<EOF"
printf '%s\n' '.temp/idedata-*.json' '.temp/idedata-*.hash'
printf '.temp/**/*.%s\n' h hpp hh hxx inc inl ipp tpp
echo "EOF"
} >> "$GITHUB_OUTPUT"
# Mirror cache-esp-idf: write on dev, restore-only on PRs. The post-step
# save only runs when the job succeeded, so a failed generation is never saved.
# Extend the extension list if a component ships extensionless headers.
- name: Cache clang-tidy idedata (write on dev)
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && steps.key.outputs.skip != 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.key.outputs.path }}
key: ${{ steps.key.outputs.key }}
- name: Cache clang-tidy idedata (restore-only off dev)
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') && steps.key.outputs.skip != 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.key.outputs.path }}
key: ${{ steps.key.outputs.key }}
+21 -5
View File
@@ -26,6 +26,9 @@ runs:
# The native-IDF version is pinned in code, not in any file that feeds the
# other cache keys, so resolve it explicitly. Keying on it means the cache
# invalidates on a version bump (actions/cache never overwrites a key).
# Also key on the Python version: the cached IDF venv links to the
# runner's toolcache interpreter and is reinstalled every run after a
# runner image bump.
id: version
shell: bash
run: |
@@ -36,19 +39,32 @@ runs:
version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "python-version=$(python -c 'import platform; print(platform.python_version())')" >> "$GITHUB_OUTPUT"
# Mirror the adjacent PlatformIO cache: only dev-branch runs write the
# shared cache (so it lives in the default-branch scope readable by all
# PRs), and PRs are restore-only -- they never push multi-GB artifacts into
# their own scope / the repo quota (e.g. on a version-bump PR).
# their own scope / the repo quota (e.g. on a version-bump PR). The
# ci-cache-write label lets a PR write into its own scope to test the hit path;
# that costs about 1GB of the repo cache quota per run, so remove it when done.
# -slim: bump when prune-esp-idf changes what it removes; a key is never overwritten.
- name: Cache ESP-IDF install (write on dev)
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && inputs.restore-only != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
- name: Cache ESP-IDF install (restore-only off dev)
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') || inputs.restore-only == 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
# Install explicitly so the prune below sees the toolchains on a cache miss
# too, instead of the install happening inside the first build step.
- name: Install ESP-IDF
shell: bash
run: |
. venv/bin/activate
python -c 'from esphome.espidf.framework import check_esp_idf_install; check_esp_idf_install("${{ steps.version.outputs.version }}")'
- name: Prune ESP-IDF install
uses: ./.github/actions/prune-esp-idf
+34
View File
@@ -0,0 +1,34 @@
name: Prune ESP-IDF install
description: >
Remove the picolibc sysroots (1.1GB of the 3.9GB install) from the native
ESP-IDF toolchains; IDF 5.x links newlib. Skipped when an IDF 6 install is
present, which links picolibc (see esp32/__init__.py).
runs:
using: composite
steps:
- name: Prune picolibc
shell: bash
run: |
shopt -s nullglob
prefix="${ESPHOME_ESP_IDF_PREFIX:-$HOME/.esphome-idf}"
prefix="${prefix/#\~/$HOME}"
for fw in "$prefix"/frameworks/*/; do
case "$(basename "$fw")" in
[6-9].*) echo "IDF $(basename "$fw") installed, keeping picolibc"; exit 0 ;;
esac
done
n=0
for dir in "$prefix"/tools/*-esp-elf/*/*-esp-elf/picolibc; do
echo "Removing $dir ($(du -sh "$dir" | cut -f1))"
rm -rf "$dir"
n=$((n + 1))
done
# The marker rides along in the cache entry so a restored slim tree stays quiet.
if [ "$n" -gt 0 ]; then
touch "$prefix/.picolibc-pruned"
elif [ -d "$prefix/tools" ] && [ ! -f "$prefix/.picolibc-pruned" ]; then
echo "::warning::no picolibc sysroots matched under $prefix/tools"
fi
if [ -d "$prefix" ]; then
du -sh "$prefix"
fi
+64
View File
@@ -355,6 +355,15 @@ jobs:
fail-fast: false
matrix:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
env:
# What the cache steps persist; libdeps is excluded (keyed per xdist
# worker and env, it never crosses runs).
INTEGRATION_PIO_CACHE_PATH: |
~/.esphome-integration-tests/platformio/platforms
~/.esphome-integration-tests/platformio/packages
~/.esphome-integration-tests/platformio/appstate.json
~/.esphome-integration-tests/platformio/.cache
~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -373,6 +382,14 @@ jobs:
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore integration PlatformIO cache
# Native platform + toolchain installed by shared_platformio_cache in
# tests/integration/conftest.py; a miss self-heals, so no restore-keys.
id: pio-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -416,6 +433,13 @@ jobs:
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save integration PlatformIO cache
# Bucket 0 only; the others would race the same immutable key.
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
key: ${{ steps.pio-cache.outputs.cache-primary-key }}
import-time:
name: Check import esphome.__main__ time
@@ -649,6 +673,12 @@ jobs:
with:
framework: arduino
- name: Cache clang-tidy idedata
if: matrix.cache_idf
uses: ./.github/actions/cache-clang-tidy-idedata
with:
environment: esp32-arduino-tidy
- name: Cache nRF Connect SDK install
if: matrix.cache_sdk_nrf
uses: ./.github/actions/cache-sdk-nrf
@@ -698,6 +728,10 @@ jobs:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Prune ESP-IDF install before cache save
if: matrix.cache_idf && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
uses: ./.github/actions/prune-esp-idf
- name: Suggested changes
run: script/ci-suggest-changes ${{ matrix.ignore_errors && '|| true' || '' }}
# yamllint disable-line rule:line-length
@@ -730,6 +764,11 @@ jobs:
- name: Cache ESP-IDF install
uses: ./.github/actions/cache-esp-idf
- name: Cache clang-tidy idedata
uses: ./.github/actions/cache-clang-tidy-idedata
with:
environment: esp32-idf-tidy
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
@@ -764,6 +803,10 @@ jobs:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Prune ESP-IDF install before cache save
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
uses: ./.github/actions/prune-esp-idf
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
@@ -809,6 +852,11 @@ jobs:
- name: Cache ESP-IDF install
uses: ./.github/actions/cache-esp-idf
- name: Cache clang-tidy idedata
uses: ./.github/actions/cache-clang-tidy-idedata
with:
environment: esp32-idf-tidy
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
@@ -843,6 +891,10 @@ jobs:
# Also cache libdeps, store them in a ~/.platformio subfolder
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
- name: Prune ESP-IDF install before cache save
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
uses: ./.github/actions/prune-esp-idf
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
@@ -866,16 +918,19 @@ jobs:
name: Run script/clang-tidy for ESP32 S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
tidy_environment: esp32s3-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
# so their code paths differ -- lint them under the P4 build too.
# yamllint disable-line rule:line-length
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
tidy_environment: esp32p4-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
# yamllint disable-line rule:line-length
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
tidy_environment: esp32c6-idf-tidy
steps:
- name: Check out code from GitHub
@@ -893,6 +948,11 @@ jobs:
- name: Cache ESP-IDF install
uses: ./.github/actions/cache-esp-idf
- name: Cache clang-tidy idedata
uses: ./.github/actions/cache-clang-tidy-idedata
with:
environment: ${{ matrix.tidy_environment }}
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
@@ -926,6 +986,10 @@ jobs:
script/clang-tidy --fix --changed ${{ matrix.options }}
fi
- name: Prune ESP-IDF install before cache save
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
uses: ./.github/actions/prune-esp-idf
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
+1
View File
@@ -350,6 +350,7 @@ esphome/components/mipi_spi/* @clydebarrow
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
esphome/components/mk2pvrouter/* @FredM67
esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz
esphome/components/mmc5603/* @benhoff
+16 -17
View File
@@ -1670,20 +1670,26 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
if exit_code != 0:
return exit_code
if CORE.is_host:
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
program_path = str(toolchain.get_elf_path())
else:
from esphome.platformio.toolchain import get_idedata
program_path = str(get_idedata(config).firmware_elf_path)
_LOGGER.info("Successfully compiled program to path '%s'", program_path)
_LOGGER.info(
"Successfully compiled program to path '%s'", _host_program_path(config)
)
else:
_LOGGER.info("Successfully compiled program.")
return 0
def _host_program_path(config: ConfigType) -> str:
"""Return the compiled host ELF path."""
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
return str(toolchain.get_elf_path())
from esphome.platformio.toolchain import get_idedata
# Memoized by compile_program's own call; this is a dict lookup
return str(get_idedata(config).firmware_elf_path)
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
# Get devices, resolving special identifiers like OTA
devices = choose_upload_log_host(
@@ -1728,14 +1734,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
return exit_code
_LOGGER.info("Successfully compiled program.")
if CORE.is_host:
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
program_path = str(toolchain.get_elf_path())
else:
from esphome.platformio.toolchain import get_idedata
program_path = str(get_idedata(config).firmware_elf_path)
program_path = _host_program_path(config)
_LOGGER.info("Running program from path '%s'", program_path)
return run_external_process(program_path)
View File
+531
View File
@@ -0,0 +1,531 @@
"""Arduino-core backend for the shared PlatformIO library converter.
Bundled names build straight from the framework tree; everything else goes
through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each
library builds its own archive; all include dirs join one global path.
Deviations from PlatformIO: flat-layout libraries get the recursive default
source filter; ``dot_a_linkage`` is honored; bundled libraries never run a
manifest ``extraScript``; manifest ``-I`` flags join the global include path;
``precompiled``/``ldflags`` properties are refused by name.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
from esphome.core import CORE, EsphomeError, Library
from esphome.helpers import walk_files
from esphome.platformio.extra_script import apply_extra_script
from esphome.platformio.library import (
DEFAULT_BUILD_INCLUDE_DIR,
DEFAULT_BUILD_SRC_FILTER,
ESPHOME_DATA_KEY,
ESPHOME_DATA_LINK_FLAGS_KEY,
LIBRARY_HEADER_SUFFIXES,
SRC_FILE_EXTENSIONS,
ConvertedLibrary,
IncompatiblePlatform,
InvalidLibrary,
LibraryBackend,
_url_or_none,
check_library_data,
collect_filtered_files,
convert_libraries,
ensure_list,
is_lib_ignored,
lex_build_flags,
lib_ignore_set,
normalize_dependencies,
parse_library_json,
parse_library_properties,
warn_properties_depends,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class ArduinoLibrary:
"""One resolved library, ready for the ninja generator."""
name: str
sources: list[Path] = field(default_factory=list)
include_dirs: list[Path] = field(default_factory=list)
# Extra compile flags private to this library's own sources
flags: list[str] = field(default_factory=list)
# PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the
# objects go to the linker directly (symbols nothing references survive)
lib_archive: bool = True
# Link inputs the library contributes (-L dirs / -l libs, e.g. from
# precompiled vendor blobs) and -Wl, options for the firmware link
link_dirs: list[Path] = field(default_factory=list)
link_libs: list[str] = field(default_factory=list)
link_flags: list[str] = field(default_factory=list)
# Source-like suffixes the case-sensitive suffix map rejects
_UNMAPPED_SOURCE_SUFFIXES = frozenset(
{s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"}
)
# Filename-plain names: an allowlist excludes separators, drive colons,
# and dot-only names by shape
_SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z")
def _is_safe_library_name(name: object) -> bool:
"""Whether a name may be joined under the framework's libraries dir."""
return isinstance(name, str) and _SAFE_LIBRARY_NAME_RE.fullmatch(name) is not None
def _manifest_build(name: str, data: object) -> dict:
"""The manifest's ``build`` section; malformed manifests fail by name."""
build = data.get("build", {}) if isinstance(data, dict) else None
if not isinstance(build, dict):
raise EsphomeError(f"Library {name} has a malformed manifest")
return build
def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str:
"""Resolve PIO's source dir: manifest srcDir, else src/Src, else the root."""
if "srcDir" not in build:
return next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".")
# A declared srcDir (falsy included) that does not resolve is a manifest error
src_dir = build["srcDir"]
if not (isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()):
raise EsphomeError(
f"Library {name} declares srcDir {src_dir!r} which does not exist"
)
return src_dir
def _reject_unsupported_link_fields(name: str, data: dict) -> None:
# PIO honors these; ignoring them would fail at link with no stated
# cause. Property values are strings, so "false" is not a declaration.
precompiled = data.get("precompiled")
if precompiled and str(precompiled).strip().lower() != "false":
raise EsphomeError(
f"Library {name} declares precompiled, which this backend does not support"
)
if data.get("ldflags"):
raise EsphomeError(
f"Library {name} declares ldflags, which this backend does not support"
)
def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool:
"""build.libArchive, else dot_a_linkage (an Arduino IDE property PIO
ignores; a deliberate extra), else archive."""
# Strict parse: bool("false") is True
def _parse(key: str, raw: object) -> bool:
if isinstance(raw, bool):
return raw
value = str(raw).strip().lower()
if value in ("true", "false"):
return value == "true"
raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}")
if "libArchive" in build:
return _parse("libArchive", build["libArchive"])
if "dot_a_linkage" in data:
return _parse("dot_a_linkage", data["dot_a_linkage"])
return True
def _classify_build_flags(
name: str, read_path: Path, lib: ArduinoLibrary, flag_tokens: list[str]
) -> list[str]:
"""Route the lexed build.flags into the library's flag lists.
Returns the ``-I`` arguments for the include-dir resolution.
"""
include_flags: list[str] = []
for tok in flag_tokens:
if tok.startswith("-I"):
include_flags.append(tok[2:])
elif tok.startswith("-L"):
link_dir = (read_path / tok[2:]).resolve()
if not link_dir.is_dir():
# Kept (the linker ignores missing -L dirs); the warning
# names the culprit before a bare "cannot find -lfoo"
_LOGGER.warning(
"Library %s declares library dir %s which does not exist",
name,
tok[2:],
)
lib.link_dirs.append(link_dir)
elif tok.startswith("-l"):
lib.link_libs.append(tok[2:])
elif tok.startswith("-Wl,"):
lib.link_flags.append(tok)
else:
lib.flags.append(tok)
return include_flags
def _resolve_include_dirs(
name: str,
read_path: Path,
lib: ArduinoLibrary,
build: dict,
src_dir: str,
include_flags: list[str],
) -> None:
include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
if not isinstance(include_dir, str):
raise EsphomeError(f"Library {name} has a malformed includeDir")
for d, explicit in [
(include_dir, "includeDir" in build),
(src_dir, False), # _resolve_src_dir already validated it
*((flag, True) for flag in include_flags),
]:
if (path := (read_path / d)).is_dir():
lib.include_dirs.append(path.resolve())
elif explicit:
# Warn-and-drop (unlike srcDir): a missing include dir is
# harmless until a header is needed, and the compile names it
_LOGGER.warning(
"Library %s declares include dir %s which does not exist", name, d
)
def _collect_lib_sources(
name: str,
read_path: Path,
lib: ArduinoLibrary,
src_dir: str,
src_filter: list[str],
) -> None:
sources: list[Path] = []
dropped: list[str] = []
saw_header = False
for f in collect_filtered_files(read_path / src_dir, src_filter):
path = Path(f)
suffix = path.suffix
if suffix in SRC_FILE_EXTENSIONS:
# resolve() per file: srcFilter patterns may escape src_dir
sources.append(path.resolve())
elif suffix.lower() in _UNMAPPED_SOURCE_SUFFIXES:
# A source-like suffix the case-sensitive map rejects (.CPP,
# .ino) is a dropped compilation unit; headers fall through
dropped.append(path.name)
elif suffix.lower() in LIBRARY_HEADER_SUFFIXES:
saw_header = True
lib.sources = sorted(sources)
if dropped:
_LOGGER.warning(
"Library %s: %d file(s) with unmapped source suffixes are not compiled: %s",
name,
len(dropped),
", ".join(sorted(dropped)),
)
if not lib.sources and not saw_header:
# Matched headers mean header-only; a filter matching nothing is
# a manifest/tree problem (a truly empty tree raises elsewhere)
_LOGGER.warning("Library %s: no source files matched", name)
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
"""Resolve one library's sources, include dirs, and flags (PIO semantics)."""
build = _manifest_build(name, data)
_reject_unsupported_link_fields(name, data)
src_dir = _resolve_src_dir(name, read_path, build)
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
if not all(isinstance(entry, str) for entry in src_filter):
raise EsphomeError(f"Library {name} has a malformed srcFilter")
lib = ArduinoLibrary(name=name, lib_archive=_resolve_lib_archive(name, data, build))
# PlatformIO shell-lexes each build.flags entry
include_flags = _classify_build_flags(
name, read_path, lib, lex_build_flags(build.get("flags", []), f"library {name}")
)
_resolve_include_dirs(name, read_path, lib, build, src_dir, include_flags)
_collect_lib_sources(name, read_path, lib, src_dir, src_filter)
return lib
def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
"""A library bundled with the Arduino core, read from the framework tree.
``library.json`` wins over ``library.properties`` when both exist, as in
PlatformIO's LibBuilderFactory; only the JSON manifest can carry a
``build`` section (srcDir, srcFilter, flags).
"""
lib_dir = framework_path / "libraries" / name
manifest_json = lib_dir / "library.json"
if manifest_json.is_file():
try:
data = parse_library_json(manifest_json)
except ValueError as err: # JSONDecodeError
raise EsphomeError(
f"Bundled library {name} has a corrupt library.json ({err}); "
"the framework install may be incomplete (run 'esphome clean-all')"
) from err
elif (manifest := lib_dir / "library.properties").is_file():
data = parse_library_properties(manifest)
else:
# Debug, not warning: the legacy manifest-less layout is legal and
# the 3.1.2 core ships one such library (FSTools), so a warning
# would be unactionable noise on every build using it
_LOGGER.debug("Bundled library %s has no manifest; using defaults", name)
data = {}
if isinstance(data, dict):
# Bundled manifest deps are never walked; make the skip visible
if data.get("dependencies"):
_LOGGER.warning(
"Bundled library %s declares dependencies, which are not "
"resolved automatically; add them with add_library() if needed",
name,
)
warn_properties_depends(name, data)
build = data.get("build")
if isinstance(build, dict) and build.get("extraScript"):
# Scripts only run on the converted path; building without
# the script's flags would miscompile
raise EsphomeError(
f"Bundled library {name} declares an extraScript, which is "
"not run for bundled libraries"
)
lib = _library_info(name, lib_dir, data)
_assert_tree_has_code(
name,
lib_dir,
"the framework install may be incomplete (run 'esphome clean-all')",
)
return lib
def _assert_tree_has_code(name: str, root: Path, hint: str) -> None:
"""An empty or half-extracted tree can never link; fail by name (a
warning would scroll away and resurface as undefined symbols)."""
if not any(
Path(p).suffix in SRC_FILE_EXTENSIONS
or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES
for p in walk_files(root)
):
raise EsphomeError(f"Library {name} has no sources or headers; {hint}")
def _external_short_name(name: str) -> str:
"""The short library name of a requested spec.
"owner/Name" and plain names take the last path segment; "Name=<url>"
takes the declared name. Git tails (".git", "#ref") are stripped like
the walk's URL normalization; the comparand is a manifest dependency
name, never a spec.
"""
head, sep, tail = name.partition("=")
if sep and "://" in tail:
return head
short = name.rsplit("/", maxsplit=1)[-1]
return short.partition("#")[0].removesuffix(".git")
def _check_unfulfilled_provides(
provided_requests: set[str], satisfied: set[str], still_requested: set[str]
) -> None:
"""Fail by name when a walk-skipped dependency was never added.
An unfulfilled provides() promise only surfaces as undefined symbols
at link. The walk records across re-resolutions, so a name no final
manifest still requests is stale state, never a failure.
"""
if missing := sorted((provided_requests & still_requested) - satisfied):
raise EsphomeError(
"provides() skipped these dependencies but nothing added them: "
f"{', '.join(missing)}; the build is missing libraries"
)
def resolve_libraries(
framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str
) -> list[ArduinoLibrary]:
"""Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`.
``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would
for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the
shared converter's download cache.
The returned list is not topologically sorted, so the caller must link
the archives inside one ``--start-group``/``--end-group`` pair (the
bundled-first grouping is incidental).
"""
bundled: list[ArduinoLibrary] = []
external: list[Library] = []
# PlatformIO's lib_ignore covers framework-bundled libraries too; the
# shared converter only filters the registry/git ones.
lib_ignore = lib_ignore_set()
# Exact directory names keep membership case-sensitive everywhere
# (an is_dir() probe would match "wire" on macOS/Windows and build
# the bundled Wire twice)
libraries_dir = framework_path / "libraries"
if not libraries_dir.is_dir():
# A registry fallback would fail later with a misleading
# package-not-found error per bundled name
raise EsphomeError(
f"{libraries_dir} is missing; the framework install may be "
"incomplete (run 'esphome clean-all')"
)
bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir())
def _provided(name: object) -> bool:
return _is_safe_library_name(name) and name in bundled_dir_names
for library in CORE.platformio_libraries.values():
if is_lib_ignored(library.name, lib_ignore):
continue
# Bundled only for a bare name with a matching framework dir; pinned
# or unmatched names resolve from the registry, as under PlatformIO.
if not library.repository and not library.version and _provided(library.name):
# Bundled manifest deps are not walked; _bundled_library warns
bundled.append(_bundled_library(framework_path, library.name))
else:
external.append(library)
converted: list[ArduinoLibrary] = []
bundled_names = {lib.name for lib in bundled}
converted_manifest_names: set[str] = set()
# Bundled candidates skipped on purpose (platform filter); the
# provides() reconciliation must count them as satisfied
knowingly_skipped: set[str] = set()
# Dependency names of the manifests actually emitted; a walk recording
# for a since-re-resolved manifest must not fail the reconciliation
final_dep_names: set[str] = set()
# Ordered set of bundled dependency names to add once conversion is done
pending_bundled: dict[str, None] = {}
# Deps matching a separately-requested external are already in the build
# (a duplicate archive means duplicate-symbol link errors)
external_short_names = {
_external_short_name(lib.name) for lib in external if lib.name
}
def _add_bundled_dependencies(component: ConvertedLibrary) -> None:
# A version-less bare name ("Hash") is a core-bundled library the
# shared converter cannot resolve from the registry
for dep in normalize_dependencies(
component.data.get("dependencies"), component.name
):
# normalize_dependencies guarantees a non-empty str name
name = dep["name"]
final_dep_names.add(name)
if "/" in name:
owner, _, pkg = name.partition("/")
if _is_safe_library_name(owner) and _is_safe_library_name(pkg):
# Owner-qualified; the converter resolves it from the registry
continue
if not _is_safe_library_name(name):
# The name becomes a path component; never join a traversal
_LOGGER.warning(
"Ignoring malformed dependency entry %r of library %s",
dep,
component.name,
)
continue
if name in external_short_names:
if _provided(name):
# A bundled copy is suppressed; a coincidental name
# collision would surface as link errors
_LOGGER.warning(
"Dependency %s of %s is assumed satisfied by a "
"requested external library; the bundled copy is "
"not added",
name,
component.name,
)
else:
_LOGGER.debug(
"Dependency %s of %s assumed satisfied by a requested "
"external library",
name,
component.name,
)
continue
if name in bundled_names or is_lib_ignored(name, lib_ignore):
continue
if _url_or_none(dep.get("version")) is not None:
# A URL names one specific source; never add the bundled copy
continue
if dep.get("owner") or not _provided(name):
# Only owner-less framework-tree names take the bundled
# copy (PIO's process_dependencies); the walk reports drops
continue
try:
# framework=None: the walk already warned for non-platform
# causes; debug keeps one fault from warning twice (pinned
# by test_nonplatform_rejection_warns_once_through_real_converter)
check_library_data(dep, pio_platform, None)
except IncompatiblePlatform as err:
# A knowing skip (platform filter), not a broken promise
knowingly_skipped.add(name)
_LOGGER.debug("Skip bundled candidate %s: %s", name, err)
continue
except InvalidLibrary as err:
# Malformed manifest data never counts as satisfied; the
# walk owns the warning (see the warns-once test above)
_LOGGER.debug("Skip malformed bundled candidate %s: %s", name, err)
continue
# Deferred: a later manifest name may satisfy this
pending_bundled.setdefault(name)
def _emit(component: ConvertedLibrary) -> None:
apply_extra_script(
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
)
_assert_tree_has_code(
component.get_require_name(),
component.source_dir,
"the download may be incomplete (run 'esphome clean-all')",
)
if isinstance(manifest_name := component.data.get("name"), str):
converted_manifest_names.add(manifest_name)
lib = _library_info(
component.get_require_name(), component.source_dir, component.data
)
# Extra-script LINKFLAGS travel outside build.flags; dropping
# them would link wrong with no stated cause
lib.link_flags.extend(
component.data.get(ESPHOME_DATA_KEY, {}).get(
ESPHOME_DATA_LINK_FLAGS_KEY, []
)
)
converted.append(lib)
_add_bundled_dependencies(component)
backend = LibraryBackend(
platform=pio_platform,
framework="arduino",
emit=_emit,
cache_key=cache_key,
# The walk must not resolve bundled names from the registry;
# _add_bundled_dependencies adds them after emit
provides=_provided,
)
if external:
convert_libraries(external, backend)
for name in pending_bundled:
if name in converted_manifest_names:
# The converted library is this one; the bundled copy would
# double the archive. Warn like the external_short_names twin.
_LOGGER.warning(
"Dependency %s is assumed satisfied by a converted library's "
"manifest name; the bundled copy is not added",
name,
)
continue
bundled_names.add(name)
bundled.append(_bundled_library(framework_path, name))
_check_unfulfilled_provides(
backend.provided_requests,
bundled_names
| converted_manifest_names
| external_short_names
| knowingly_skipped,
final_dep_names,
)
return bundled + converted
+108
View File
@@ -0,0 +1,108 @@
"""Tiny cross-platform build steps invoked from the generated ninja file.
Plain script (not ``python -m``): it runs from ninja with whatever Python
started esphome and must not depend on the package being importable.
Subcommands:
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
copy <src> <dst> copy a file
The ar rspfile carries one object path per line (the generating rule must
use ``$in_newline``, never ``$in``).
"""
from pathlib import Path
import shutil
import subprocess
import sys
def _read_rspfile(rspfile: str) -> list[str]:
r"""The object paths listed in ``rspfile``, unquoted.
GNU ar treats backslashes in response files as escapes (corrupts
Windows paths), so the caller expands the list into argv; strip the
simple surrounding quote ninja adds to special paths, then undo
ninja's POSIX escape for an embedded quote ('a'\\''b.o' -> a'b.o).
"""
return [
line[1:-1].replace("'\\''", "'")
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\""
else line
for line in Path(rspfile).read_text(encoding="utf-8").splitlines()
if line
]
def _run_ar(ar: str, archive: str, rspfile: str) -> int:
# Remove first: ``ar rcs`` replaces members but never drops ones whose
# source was removed from the build, which would leak stale objects.
Path(archive).unlink(missing_ok=True)
objects = _read_rspfile(rspfile)
if not objects:
# An empty archive would "succeed" here and fail far away at link
print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr)
return 1
# Batch by argv length: expanding the rspfile gives back the Windows
# 32767-char command-line limit it existed to avoid. "rcs" creates,
# "qs" appends; the s keeps the symbol index explicit on every ar.
op = "rcs"
ok = False
try:
while objects:
batch = [objects.pop(0)]
batch_len = len(batch[0])
while objects and batch_len + len(objects[0]) < 25000:
batch_len += len(objects[0]) + 1
batch.append(objects.pop(0))
rc = subprocess.run(
[ar, op, archive, *batch], check=False, close_fds=False
).returncode
if rc != 0:
return rc
op = "qs"
ok = True
return 0
finally:
if not ok:
# Any failure (bad exit, missing ar binary, interrupt) must not
# leave a truncated archive behind
Path(archive).unlink(missing_ok=True)
def _run_copy(src: str, dst: str) -> int:
try:
shutil.copyfile(src, dst)
except OSError as err:
# Never leave a partially written output (e.g. a firmware image);
# SameFileError means dst IS src, where unlinking destroys the input
if not isinstance(err, shutil.SameFileError):
Path(dst).unlink(missing_ok=True)
print(f"copy: {src} -> {dst} failed: {err}", file=sys.stderr)
return 1
return 0
# mode -> (handler, expected operand count); surplus argv means a
# mis-specified ninja rule and must error, not silently drop operands
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
def main() -> int:
mode = sys.argv[1] if len(sys.argv) > 1 else ""
if entry := _MODES.get(mode):
handler, argc = entry
args = sys.argv[2:]
if len(args) != argc:
print(
f"build_tool {mode}: expected {argc} arguments, got {len(args)}",
file=sys.stderr,
)
return 1
return handler(*args)
print(f"unknown build_tool mode: {mode}", file=sys.stderr)
return 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+1
View File
@@ -78,6 +78,7 @@ from esphome.cpp_types import ( # noqa: F401
StringRef,
arduino_json_ns,
bool_,
char,
const_char_ptr,
double,
esphome_ns,
+125 -13
View File
@@ -5,6 +5,7 @@ from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
@@ -41,10 +42,12 @@ from esphome.const import (
CONF_TAG,
CONF_THEN,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_VARIABLES,
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.helpers import fnv1_hash
from esphome.types import ConfigFragmentType, ConfigType
# Compat alias: downstream consumers (e.g. device-builder) referenced the
@@ -125,6 +128,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
}
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_EXAMPLE = "example"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
@@ -228,14 +232,30 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small
ESP8266_ACTION_STRINGS_MAX_TOTAL = 384
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
}
)
# Accepts the plain `name: type` shorthand or the full mapping form
validate_variable = cv.maybe_simple_value(VARIABLE_SCHEMA, key=CONF_TYPE)
ACTIONS_SCHEMA = automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger),
cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name,
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name,
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
{
cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.validate_id_name: validate_variable,
}
),
# No default - auto-detected by _auto_detect_supports_response
@@ -352,6 +372,85 @@ CONFIG_SCHEMA = cv.All(
)
def _has_action_metadata(actions: list[ConfigType]) -> bool:
# Empty strings count as unset, matching _action_strings
return any(
conf.get(CONF_DESCRIPTION)
or any(
var_.get(CONF_DESCRIPTION) or var_.get(CONF_EXAMPLE)
for var_ in conf[CONF_VARIABLES].values()
)
for conf in actions
)
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
# An empty description or example is treated as unset
strings: list[str | None] = [conf[CONF_ACTION]]
if has_metadata:
strings.append(conf.get(CONF_DESCRIPTION) or None)
for name, var_ in conf[CONF_VARIABLES].items():
strings.append(name)
if has_metadata:
strings += [
var_.get(CONF_DESCRIPTION) or None,
var_.get(CONF_EXAMPLE) or None,
]
return strings
def _action_strings_size(strings: list[str | None]) -> int:
"""Bytes needed to copy every string out of flash, each with its terminator."""
return sum(
len(string.encode("utf-8")) + 1 for string in strings if string is not None
)
def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
if not CORE.is_esp8266:
return config
actions = config.get(CONF_ACTIONS, [])
has_metadata = _has_action_metadata(actions)
for conf in actions:
size = _action_strings_size(_action_strings(conf, has_metadata))
if size > ESP8266_ACTION_STRINGS_MAX_TOTAL:
raise cv.Invalid(
f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, "
f"description and example text; ESP8266 allows at most "
f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
def _add_action_strings(
index: int, strings: list[str | None], interned: dict[str, MockObj]
) -> MockObj:
"""Emit the PROGMEM string table for one action.
Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical
strings are shared between actions through `interned`.
"""
entries: list[MockObj] = []
for string in strings:
if string is None:
entries.append(cg.nullptr)
continue
if (var := interned.get(string)) is None:
var = interned[string] = cg.progmem_array(
ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char),
string,
)
entries.append(var)
return cg.progmem_array(
ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr),
entries,
)
@coroutine_with_priority(CoroPriority.WEB)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -371,8 +470,10 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
actions = config.get(CONF_ACTIONS, [])
has_user_actions = bool(actions) or config[CONF_CUSTOM_SERVICES]
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]:
if has_user_actions:
cg.add_define("USE_API_USER_DEFINED_ACTIONS")
# Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration
@@ -385,10 +486,17 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_HOMEASSISTANT_STATES]:
cg.add_define("USE_API_HOMEASSISTANT_STATES")
if actions := config.get(CONF_ACTIONS, []):
scratch_size = 0
if actions:
# Metadata is compiled in for every action once any action declares it, because the
# string table layout is fixed by the define rather than per action
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
interned_strings: dict[str, MockObj] = {}
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for conf in actions:
for index, conf in enumerate(actions):
func_args: list[tuple[MockObj, str]] = []
service_template_args: list[MockObj] = [] # User service argument types
@@ -421,22 +529,23 @@ async def to_code(config: ConfigType) -> None:
conf.get(CONF_THEN, [])
)
service_arg_names: list[str] = []
for name, var_ in conf[CONF_VARIABLES].items():
if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES:
native = SERVICE_ARG_FALLBACK_TYPES[var_]
var_type = var_[CONF_TYPE]
if has_non_synchronous and var_type in SERVICE_ARG_FALLBACK_TYPES:
native = SERVICE_ARG_FALLBACK_TYPES[var_type]
else:
native = SERVICE_ARG_NATIVE_TYPES[var_]
native = SERVICE_ARG_NATIVE_TYPES[var_type]
service_template_args.append(native)
func_args.append((native, name))
service_arg_names.append(name)
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings, interned_strings)
if CORE.is_esp8266:
scratch_size = max(scratch_size, _action_strings_size(strings))
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
# Key is hashed here because the name is not readable at runtime on ESP8266
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID],
templ,
conf[CONF_ACTION],
service_arg_names,
conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION])
)
triggers.append(trigger)
auto = await automation.build_automation(trigger, func_args, conf)
@@ -458,6 +567,9 @@ async def to_code(config: ConfigType) -> None:
cg.add(auto.add_actions([unregister_action]))
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
if CORE.is_esp8266 and has_user_actions:
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
if CONF_ON_CLIENT_CONNECTED in config:
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
+3
View File
@@ -1034,6 +1034,8 @@ message ListEntitiesServicesArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
}
message ListEntitiesServicesResponse {
option (id) = 41;
@@ -1044,6 +1046,7 @@ message ListEntitiesServicesResponse {
fixed32 key = 2 [(force) = true];
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
SupportsResponseType supports_response = 4;
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
}
message ExecuteServiceArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
+18
View File
@@ -1275,12 +1275,24 @@ uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENC
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example);
#endif
return pos;
}
uint32_t ListEntitiesServicesArgument::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->name.size());
size += this->type ? 2 : 0;
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, this->description.size());
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, this->example.size());
#endif
return size;
}
uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1291,6 +1303,9 @@ uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENC
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it);
}
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(this->supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description);
#endif
return pos;
}
uint32_t ListEntitiesServicesResponse::calculate_size() const {
@@ -1303,6 +1318,9 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const {
}
}
size += this->supports_response ? 2 : 0;
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, this->description.size());
#endif
return size;
}
bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+10 -1
View File
@@ -1317,6 +1317,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
public:
StringRef name{};
enums::ServiceArgType type{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef example{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -1328,7 +1334,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
class ListEntitiesServicesResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 41;
static constexpr uint8_t ESTIMATED_SIZE = 50;
static constexpr uint8_t ESTIMATED_SIZE = 59;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); }
#endif
@@ -1336,6 +1342,9 @@ class ListEntitiesServicesResponse final : public ProtoMessage {
uint32_t key{0};
FixedVector<ListEntitiesServicesArgument> args{};
enums::SupportsResponseType supports_response{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
+9
View File
@@ -1500,6 +1500,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ServiceArgType>(this->type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("example"), this->example);
#endif
return out.c_str();
}
const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
@@ -1512,6 +1518,9 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
out.append("\n");
}
dump_field(out, ESPHOME_PSTR("supports_response"), static_cast<enums::SupportsResponseType>(this->supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
#endif
return out.c_str();
}
const char *ExecuteServiceArgument::dump_to(DumpBuffer &out) const {
+2 -1
View File
@@ -99,7 +99,8 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
auto resp = service->encode_list_service_response();
UserActionScratch scratch;
auto resp = service->encode_list_service_response(scratch);
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
+43
View File
@@ -1,9 +1,52 @@
#include "user_services.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
namespace esphome::api {
StringRef UserServiceStatic::str_(size_t idx, std::span<char> &scratch) const {
const char *s = progmem_read_ptr(&this->strings_[idx]);
if (s == nullptr)
return {};
#ifdef USE_ESP8266
// Codegen sizes the scratch buffer for the largest service; the bound only guards other callers
if (scratch.empty())
return {};
size_t len = strnlen_P(s, scratch.size() - 1);
progmem_memcpy(scratch.data(), s, len);
scratch[len] = '\0';
StringRef ref(scratch.data(), len);
scratch = scratch.subspan(len + 1);
return ref;
#else
return StringRef(s);
#endif
}
ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
std::span<const enums::ServiceArgType> arg_types, std::span<char> scratch) const {
ListEntitiesServicesResponse msg;
msg.name = this->str_(0, scratch);
msg.key = this->key_;
msg.supports_response = this->supports_response_;
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
msg.description = this->str_(1, scratch);
#endif
msg.args.init(arg_types.size());
for (size_t i = 0; i < arg_types.size(); i++) {
size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS;
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = this->str_(base, scratch);
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1, scratch);
arg.example = this->str_(base + 2, scratch);
#endif
}
return msg;
}
template<> bool get_execute_arg_value<bool>(const ExecuteServiceArgument &arg) { return arg.bool_; }
template<> int32_t get_execute_arg_value<int32_t>(const ExecuteServiceArgument &arg) {
if (arg.legacy_int != 0)
+56 -37
View File
@@ -1,5 +1,6 @@
#pragma once
#include <span>
#include <tuple>
#include <utility>
#include <vector>
@@ -19,7 +20,9 @@ class APIServer;
class UserServiceDescriptor {
public:
virtual ListEntitiesServicesResponse encode_list_service_response() = 0;
/// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into
/// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it.
virtual ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) = 0;
virtual bool execute_service(const ExecuteServiceRequest &req) = 0;
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -34,29 +37,51 @@ template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg);
template<typename T> enums::ServiceArgType to_service_arg_type();
// Base class for YAML-defined services (most common case)
// Stores only pointers to string literals in flash - no heap allocation
template<typename... Ts> class UserServiceBase : public UserServiceDescriptor {
public:
UserServiceBase(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names,
enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE)
: name_(name), arg_names_(arg_names), supports_response_(supports_response) {
this->key_ = fnv1_hash(name);
}
// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it
#ifdef USE_ESP8266
using UserActionScratch = std::array<char, API_USER_ACTION_STRINGS_SCRATCH_SIZE>;
#else
using UserActionScratch = std::array<char, 0>;
#endif
ListEntitiesServicesResponse encode_list_service_response() override {
ListEntitiesServicesResponse msg;
msg.name = StringRef(this->name_);
msg.key = this->key_;
msg.supports_response = this->supports_response_;
// Non-template base for YAML-defined services so the list-entities encoder is compiled once.
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
// the name and every argument is (name, description, example). Unset metadata is nullptr.
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
UserServiceStatic(const char *const *strings, uint32_t key,
enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE)
: strings_(strings), key_(key), supports_response_(supports_response) {}
protected:
ListEntitiesServicesResponse encode_list_service_response_(std::span<const enums::ServiceArgType> arg_types,
std::span<char> scratch) const;
/// Reference table entry `idx`; nullptr gives an empty StringRef.
/// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span
/// is advanced past the copy.
StringRef str_(size_t idx, std::span<char> &scratch) const;
const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr()
uint32_t key_;
enums::SupportsResponseType supports_response_;
};
template<typename... Ts> class UserServiceBase : public UserServiceStatic {
public:
using UserServiceStatic::UserServiceStatic;
ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) override {
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
msg.args.init(sizeof...(Ts));
for (size_t i = 0; i < sizeof...(Ts); i++) {
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = StringRef(this->arg_names_[i]);
}
return msg;
return this->encode_list_service_response_(arg_types, scratch);
}
bool execute_service(const ExecuteServiceRequest &req) override {
@@ -89,12 +114,6 @@ template<typename... Ts> class UserServiceBase : public UserServiceDescriptor {
void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence<S...> /*type*/) {
this->execute(call_id, return_response, (get_execute_arg_value<Ts>(args[S]))...);
}
// Pointers to string literals in flash - no heap allocation
const char *name_;
std::array<const char *, sizeof...(Ts)> arg_names_;
uint32_t key_{0};
enums::SupportsResponseType supports_response_{enums::SUPPORTS_RESPONSE_NONE};
};
// Separate class for custom_api_device services (rare case)
@@ -106,7 +125,7 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
this->key_ = fnv1_hash(this->name_.c_str());
}
ListEntitiesServicesResponse encode_list_service_response() override {
ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
ListEntitiesServicesResponse msg;
msg.name = StringRef(this->name_);
msg.key = this->key_;
@@ -167,8 +186,8 @@ template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_NONE, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<Ts...> {
public:
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {}
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_NONE) {}
protected:
void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); }
@@ -179,8 +198,8 @@ template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_OPTIONAL, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, bool, Ts...> {
public:
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
protected:
void execute(uint32_t call_id, bool return_response, Ts... x) override {
@@ -193,8 +212,8 @@ template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_ONLY, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, Ts...> {
public:
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {}
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_ONLY) {}
protected:
void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); }
@@ -205,8 +224,8 @@ template<typename... Ts>
class UserServiceTrigger<enums::SUPPORTS_RESPONSE_STATUS, Ts...> final : public UserServiceBase<Ts...>,
public Trigger<uint32_t, Ts...> {
public:
UserServiceTrigger(const char *name, const std::array<const char *, sizeof...(Ts)> &arg_names)
: UserServiceBase<Ts...>(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {}
UserServiceTrigger(const char *const *strings, uint32_t key)
: UserServiceBase<Ts...>(strings, key, enums::SUPPORTS_RESPONSE_STATUS) {}
protected:
void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); }
+1
View File
@@ -16,6 +16,7 @@ CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
CONF_DESCRIPTION = "description"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
+22 -1
View File
@@ -214,11 +214,13 @@ COMPILER_OPTIMIZATIONS = {
# builds that need them.
DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"app_trace", # CPU trace/SystemView support - unused by ESPHome
"bt", # Bluetooth stack - re-included by request_bluetooth(); its REQUIRES pulls the WiFi stack back
"cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing
"console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured
"driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers
"esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf
"esp_adc", # ADC driver - only needed by adc component
"esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back
"esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back
"esp_driver_dac", # DAC driver - only needed by esp32_dac component
"esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs
@@ -236,6 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component
"esp_eth", # Ethernet driver - only needed by ethernet component
"esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back
"esp_hal_ieee802154", # 802.15.4 HAL - ieee802154 pulls it back
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component
"esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server
@@ -243,8 +246,11 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"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
"esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
"espcoredump", # Core dump support - ESPHome has its own debug component
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back
"json", # cJSON library - ESPHome uses ArduinoJson instead
"mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation
"nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set
@@ -260,6 +266,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"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
"wpa_supplicant", # WPA supplicant - re-included by request_wifi() for esp_eap_client.h
)
# Additional IDF managed components to exclude for Arduino framework builds
@@ -709,6 +716,9 @@ def request_wifi(ap: bool = False) -> None:
net.wifi = True
if ap:
net.wifi_ap = True
include_builtin_idf_component("esp_wifi")
# wifi_component.cpp includes esp_eap_client.h/esp_wpa2.h
include_builtin_idf_component("wpa_supplicant")
def request_ethernet() -> None:
@@ -720,11 +730,14 @@ def request_bluetooth() -> None:
"""Request the Bluetooth controller."""
net = _network_sdkconfig()
net.bluetooth = True
include_builtin_idf_component("bt")
def request_software_coexistence() -> None:
"""Request WiFi/BT software coexistence (only valid alongside WiFi)."""
_network_sdkconfig().software_coexistence = True
# Callers include esp_coexist.h directly.
include_builtin_idf_component("esp_coex")
def add_idf_component(
@@ -2304,6 +2317,8 @@ async def _reconcile_network_sdkconfig() -> None:
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here.
# esp_wifi is excluded by default on IDF, so this only matters for Arduino
# or when bt pulls it back.
wifi_disabled = net.ethernet and not net.wifi
if wifi_disabled:
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False)
@@ -3234,7 +3249,13 @@ def _write_sdkconfig():
if write_file_if_changed(internal_path, contents):
# internal changed, update real one
write_file_if_changed(sdk_path, contents)
clean_build(clear_pio_cache=False)
if not CORE.using_toolchain_esp_idf:
# PIO's dependency tracking under-declares sdkconfig inputs
# (ldgen, linker scripts); without a clean the image can be
# unbootable (esphome#15336). The esp-idf toolchain tracks
# sdkconfig via IDF's cmake and has_outdated_files(), so a
# reconfigure suffices there; everything else fails safe.
clean_build(clear_pio_cache=False)
def _write_idf_component_yml():
+5
View File
@@ -155,6 +155,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESPNOW")
cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE])
if CORE.is_esp32:
from esphome.components.esp32 import include_builtin_idf_component
include_builtin_idf_component("esp_wifi")
if CONF_WIFI in CORE.config:
# Track the Wi-Fi channel via connect events instead of polling every loop
wifi.request_wifi_connect_state_listener()
@@ -1,105 +1,68 @@
#include "growatt_solar.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::growatt_solar {
namespace helpers = modbus::helpers;
static const char *const TAG = "growatt_solar";
static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion
void GrowattSolar::loop() {
// If update() was unable to send we retry until we can send.
if (!this->waiting_to_update_)
return;
update();
}
void GrowattSolar::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); }
void GrowattSolar::update() {
// If our last send has had no reply yet, and it wasn't that long ago, do nothing.
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_send_ < this->get_update_interval() / 2) {
return;
}
// The bus might be slow, or there might be other devices, or other components might be talking to our device.
if (!this->ready_for_immediate_send()) {
this->waiting_to_update_ = true;
return;
}
this->waiting_to_update_ = false;
this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]);
this->last_send_ = millis();
}
void GrowattSolar::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
// Other components might be sending commands to our device. But we don't get called with enough
// context to know what is what. So if we didn't do a send, we ignore the data.
if (!this->last_send_)
return;
this->last_send_ = 0;
// Also ignore the data if the message is too short. Otherwise we will publish invalid values.
if (data.size() < MODBUS_REGISTER_COUNT[this->protocol_version_] * 2)
void GrowattSolar::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return;
auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, size_t i, float unit) -> void {
// Publish a sensor if its register(s) are in this response; skipping absent registers keeps this
// correct for any read range, so the poll may be split into multiple requests.
auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
float value = encode_uint16(data[i * 2], data[i * 2 + 1]) * unit;
sensor->publish_state(value);
if (auto value = helpers::value_at<helpers::SensorValueType::U_WORD>(registers, start_address, reg))
sensor->publish_state(*value * unit);
};
auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, size_t reg1, size_t reg2, float unit) -> void {
float value = ((encode_uint16(data[reg1 * 2], data[reg1 * 2 + 1]) << 16) +
encode_uint16(data[reg2 * 2], data[reg2 * 2 + 1])) *
unit;
if (sensor != nullptr)
sensor->publish_state(value);
auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_DWORD>(registers, start_address, reg))
sensor->publish_state(*value * unit);
};
switch (this->protocol_version_) {
case RTU: {
publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER,
RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER,
RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER,
RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION,
RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
break;
@@ -107,42 +70,33 @@ void GrowattSolar::on_response(std::span<const uint8_t> request_pdu, std::span<c
case RTU2: {
publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER,
RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER,
RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER,
RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION,
RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
break;
@@ -17,59 +17,59 @@ enum GrowattProtocolVersion {
};
// Register addresses for the RTU protocol.
constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1
constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2
constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1
constexpr size_t RTU_PV1_CURRENT = 4; // length = 1
constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2
constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1
constexpr size_t RTU_PV2_CURRENT = 8; // length = 1
constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2
constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2
constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1
constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1
constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1
constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2
constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1
constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1
constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2
constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1
constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1
constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2
constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2
constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2
constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1
constexpr uint16_t RTU_INVERTER_STATUS = 0; // length = 1
constexpr uint16_t RTU_PV_ACTIVE_POWER = 1; // length = 2
constexpr uint16_t RTU_PV1_VOLTAGE = 3; // length = 1
constexpr uint16_t RTU_PV1_CURRENT = 4; // length = 1
constexpr uint16_t RTU_PV1_ACTIVE_POWER = 5; // length = 2
constexpr uint16_t RTU_PV2_VOLTAGE = 7; // length = 1
constexpr uint16_t RTU_PV2_CURRENT = 8; // length = 1
constexpr uint16_t RTU_PV2_ACTIVE_POWER = 9; // length = 2
constexpr uint16_t RTU_GRID_ACTIVE_POWER = 11; // length = 2
constexpr uint16_t RTU_GRID_FREQUENCY = 13; // length = 1
constexpr uint16_t RTU_PHASE1_VOLTAGE = 14; // length = 1
constexpr uint16_t RTU_PHASE1_CURRENT = 15; // length = 1
constexpr uint16_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2
constexpr uint16_t RTU_PHASE2_VOLTAGE = 18; // length = 1
constexpr uint16_t RTU_PHASE2_CURRENT = 19; // length = 1
constexpr uint16_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2
constexpr uint16_t RTU_PHASE3_VOLTAGE = 22; // length = 1
constexpr uint16_t RTU_PHASE3_CURRENT = 23; // length = 1
constexpr uint16_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2
constexpr uint16_t RTU_TODAY_PRODUCTION = 26; // length = 2
constexpr uint16_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2
constexpr uint16_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1
// Input register addresses for the RTU2 protocol as described
// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document.
constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1
constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2
constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1
constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1
constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2
constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1
constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1
constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2
constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2
constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1
constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1
constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1
constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2
constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1
constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1
constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2
constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1
constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1
constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2
constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2
constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
constexpr uint16_t RTU2_INVERTER_STATUS = 0; // length = 1
constexpr uint16_t RTU2_PV_ACTIVE_POWER = 1; // length = 2
constexpr uint16_t RTU2_PV1_VOLTAGE = 3; // length = 1
constexpr uint16_t RTU2_PV1_CURRENT = 4; // length = 1
constexpr uint16_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2
constexpr uint16_t RTU2_PV2_VOLTAGE = 7; // length = 1
constexpr uint16_t RTU2_PV2_CURRENT = 8; // length = 1
constexpr uint16_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2
constexpr uint16_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2
constexpr uint16_t RTU2_GRID_FREQUENCY = 37; // length = 1
constexpr uint16_t RTU2_PHASE1_VOLTAGE = 38; // length = 1
constexpr uint16_t RTU2_PHASE1_CURRENT = 39; // length = 1
constexpr uint16_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2
constexpr uint16_t RTU2_PHASE2_VOLTAGE = 42; // length = 1
constexpr uint16_t RTU2_PHASE2_CURRENT = 43; // length = 1
constexpr uint16_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2
constexpr uint16_t RTU2_PHASE3_VOLTAGE = 46; // length = 1
constexpr uint16_t RTU2_PHASE3_CURRENT = 47; // length = 1
constexpr uint16_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2
constexpr uint16_t RTU2_TODAY_PRODUCTION = 53; // length = 2
constexpr uint16_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
constexpr uint16_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void loop() override;
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void dump_config() override;
void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; }
@@ -104,9 +104,6 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD
}
protected:
bool waiting_to_update_{false};
uint32_t last_send_{0};
struct GrowattPhase {
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *current_sensor_{nullptr};
@@ -1,124 +1,71 @@
#include "havells_solar.h"
#include "havells_solar_registers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::havells_solar {
namespace helpers = modbus::helpers;
static const char *const TAG = "havells_solar";
static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers
void HavellsSolar::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < MODBUS_REGISTER_COUNT * 2) {
ESP_LOGW(TAG, "Invalid size for HavellsSolar!");
return;
}
void HavellsSolar::on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
/* Usage: returns the float value of 1 register read by modbus
Arg1: Register address * number of bytes per register
Arg2: Multiplier for final register value
*/
auto havells_solar_get_2_registers = [&](size_t i, float unit) -> float {
uint32_t temp = encode_uint32(data[i], data[i + 1], data[i + 2], data[i + 3]);
return temp * unit;
// Publish a sensor if its register(s) are in this response; skipping absent registers keeps this
// correct for any read range, so the poll may be split into multiple requests.
auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_WORD>(registers, start_address, reg))
sensor->publish_state(*value * unit);
};
/* Usage: returns the float value of 2 registers read by modbus
Arg1: Register address * number of bytes per register
Arg2: Multiplier for final register value
*/
auto havells_solar_get_1_register = [&](size_t i, float unit) -> float {
uint16_t temp = encode_uint16(data[i], data[i + 1]);
return temp * unit;
auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_DWORD>(registers, start_address, reg))
sensor->publish_state(*value * unit);
};
for (uint8_t i = 0; i < 3; i++) {
auto phase = this->phases_[i];
auto &phase = this->phases_[i];
if (!phase.setup)
continue;
float voltage = havells_solar_get_1_register(HAVELLS_PHASE_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT);
float current = havells_solar_get_1_register(HAVELLS_PHASE_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT);
if (phase.voltage_sensor_ != nullptr)
phase.voltage_sensor_->publish_state(voltage);
if (phase.current_sensor_ != nullptr)
phase.current_sensor_->publish_state(current);
publish_1_register(phase.voltage_sensor_, HAVELLS_PHASE_1_VOLTAGE + i * 2, ONE_DEC_UNIT);
publish_1_register(phase.current_sensor_, HAVELLS_PHASE_1_CURRENT + i * 2, TWO_DEC_UNIT);
}
for (uint8_t i = 0; i < 2; i++) {
auto pv = this->pvs_[i];
auto &pv = this->pvs_[i];
if (!pv.setup)
continue;
float voltage = havells_solar_get_1_register(HAVELLS_PV_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT);
float current = havells_solar_get_1_register(HAVELLS_PV_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT);
float active_power = havells_solar_get_1_register(HAVELLS_PV_1_POWER * 2 + (i * 2), MULTIPLY_TEN_UNIT);
float voltage_sampled_by_secondary_cpu =
havells_solar_get_1_register(HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU * 2 + (i * 2), ONE_DEC_UNIT);
float insulation_of_p_to_ground =
havells_solar_get_1_register(HAVELLS_PV1_INSULATION_OF_P_TO_GROUND * 2 + (i * 2), NO_DEC_UNIT);
if (pv.voltage_sensor_ != nullptr)
pv.voltage_sensor_->publish_state(voltage);
if (pv.current_sensor_ != nullptr)
pv.current_sensor_->publish_state(current);
if (pv.active_power_sensor_ != nullptr)
pv.active_power_sensor_->publish_state(active_power);
if (pv.voltage_sampled_by_secondary_cpu_sensor_ != nullptr)
pv.voltage_sampled_by_secondary_cpu_sensor_->publish_state(voltage_sampled_by_secondary_cpu);
if (pv.insulation_of_p_to_ground_sensor_ != nullptr)
pv.insulation_of_p_to_ground_sensor_->publish_state(insulation_of_p_to_ground);
publish_1_register(pv.voltage_sensor_, HAVELLS_PV_1_VOLTAGE + i * 2, ONE_DEC_UNIT);
publish_1_register(pv.current_sensor_, HAVELLS_PV_1_CURRENT + i * 2, TWO_DEC_UNIT);
publish_1_register(pv.active_power_sensor_, HAVELLS_PV_1_POWER + i, MULTIPLY_TEN_UNIT);
publish_1_register(pv.voltage_sampled_by_secondary_cpu_sensor_, HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU + i,
ONE_DEC_UNIT);
publish_1_register(pv.insulation_of_p_to_ground_sensor_, HAVELLS_PV1_INSULATION_OF_P_TO_GROUND + i, NO_DEC_UNIT);
}
float frequency = havells_solar_get_1_register(HAVELLS_GRID_FREQUENCY * 2, TWO_DEC_UNIT);
float active_power = havells_solar_get_1_register(HAVELLS_SYSTEM_ACTIVE_POWER * 2, MULTIPLY_TEN_UNIT);
float reactive_power = havells_solar_get_1_register(HAVELLS_SYSTEM_REACTIVE_POWER * 2, TWO_DEC_UNIT);
float today_production = havells_solar_get_1_register(HAVELLS_TODAY_PRODUCTION * 2, TWO_DEC_UNIT);
float total_energy_production = havells_solar_get_2_registers(HAVELLS_TOTAL_ENERGY_PRODUCTION * 2, NO_DEC_UNIT);
float total_generation_time = havells_solar_get_2_registers(HAVELLS_TOTAL_GENERATION_TIME * 2, NO_DEC_UNIT);
float today_generation_time = havells_solar_get_1_register(HAVELLS_TODAY_GENERATION_TIME * 2, NO_DEC_UNIT);
float inverter_module_temp = havells_solar_get_1_register(HAVELLS_INVERTER_MODULE_TEMP * 2, NO_DEC_UNIT);
float inverter_inner_temp = havells_solar_get_1_register(HAVELLS_INVERTER_INNER_TEMP * 2, NO_DEC_UNIT);
float inverter_bus_voltage = havells_solar_get_1_register(HAVELLS_INVERTER_BUS_VOLTAGE * 2, NO_DEC_UNIT);
float insulation_pv_n_to_ground = havells_solar_get_1_register(HAVELLS_INSULATION_OF_PV_N_TO_GROUND * 2, NO_DEC_UNIT);
float gfci_value = havells_solar_get_1_register(HAVELLS_GFCI_VALUE * 2, NO_DEC_UNIT);
float dci_of_r = havells_solar_get_1_register(HAVELLS_DCI_OF_R * 2, NO_DEC_UNIT);
float dci_of_s = havells_solar_get_1_register(HAVELLS_DCI_OF_S * 2, NO_DEC_UNIT);
float dci_of_t = havells_solar_get_1_register(HAVELLS_DCI_OF_T * 2, NO_DEC_UNIT);
if (this->frequency_sensor_ != nullptr)
this->frequency_sensor_->publish_state(frequency);
if (this->active_power_sensor_ != nullptr)
this->active_power_sensor_->publish_state(active_power);
if (this->reactive_power_sensor_ != nullptr)
this->reactive_power_sensor_->publish_state(reactive_power);
if (this->today_production_sensor_ != nullptr)
this->today_production_sensor_->publish_state(today_production);
if (this->total_energy_production_sensor_ != nullptr)
this->total_energy_production_sensor_->publish_state(total_energy_production);
if (this->total_generation_time_sensor_ != nullptr)
this->total_generation_time_sensor_->publish_state(total_generation_time);
if (this->today_generation_time_sensor_ != nullptr)
this->today_generation_time_sensor_->publish_state(today_generation_time);
if (this->inverter_module_temp_sensor_ != nullptr)
this->inverter_module_temp_sensor_->publish_state(inverter_module_temp);
if (this->inverter_inner_temp_sensor_ != nullptr)
this->inverter_inner_temp_sensor_->publish_state(inverter_inner_temp);
if (this->inverter_bus_voltage_sensor_ != nullptr)
this->inverter_bus_voltage_sensor_->publish_state(inverter_bus_voltage);
if (this->insulation_pv_n_to_ground_sensor_ != nullptr)
this->insulation_pv_n_to_ground_sensor_->publish_state(insulation_pv_n_to_ground);
if (this->gfci_value_sensor_ != nullptr)
this->gfci_value_sensor_->publish_state(gfci_value);
if (this->dci_of_r_sensor_ != nullptr)
this->dci_of_r_sensor_->publish_state(dci_of_r);
if (this->dci_of_s_sensor_ != nullptr)
this->dci_of_s_sensor_->publish_state(dci_of_s);
if (this->dci_of_t_sensor_ != nullptr)
this->dci_of_t_sensor_->publish_state(dci_of_t);
publish_1_register(this->frequency_sensor_, HAVELLS_GRID_FREQUENCY, TWO_DEC_UNIT);
publish_1_register(this->active_power_sensor_, HAVELLS_SYSTEM_ACTIVE_POWER, MULTIPLY_TEN_UNIT);
publish_1_register(this->reactive_power_sensor_, HAVELLS_SYSTEM_REACTIVE_POWER, TWO_DEC_UNIT);
publish_1_register(this->today_production_sensor_, HAVELLS_TODAY_PRODUCTION, TWO_DEC_UNIT);
publish_2_registers(this->total_energy_production_sensor_, HAVELLS_TOTAL_ENERGY_PRODUCTION, NO_DEC_UNIT);
publish_2_registers(this->total_generation_time_sensor_, HAVELLS_TOTAL_GENERATION_TIME, NO_DEC_UNIT);
publish_1_register(this->today_generation_time_sensor_, HAVELLS_TODAY_GENERATION_TIME, NO_DEC_UNIT);
publish_1_register(this->inverter_module_temp_sensor_, HAVELLS_INVERTER_MODULE_TEMP, NO_DEC_UNIT);
publish_1_register(this->inverter_inner_temp_sensor_, HAVELLS_INVERTER_INNER_TEMP, NO_DEC_UNIT);
publish_1_register(this->inverter_bus_voltage_sensor_, HAVELLS_INVERTER_BUS_VOLTAGE, NO_DEC_UNIT);
publish_1_register(this->insulation_pv_n_to_ground_sensor_, HAVELLS_INSULATION_OF_PV_N_TO_GROUND, NO_DEC_UNIT);
publish_1_register(this->gfci_value_sensor_, HAVELLS_GFCI_VALUE, NO_DEC_UNIT);
publish_1_register(this->dci_of_r_sensor_, HAVELLS_DCI_OF_R, NO_DEC_UNIT);
publish_1_register(this->dci_of_s_sensor_, HAVELLS_DCI_OF_S, NO_DEC_UNIT);
publish_1_register(this->dci_of_t_sensor_, HAVELLS_DCI_OF_T, NO_DEC_UNIT);
}
void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); }
@@ -77,7 +77,8 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void dump_config() override;
@@ -1,5 +1,7 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.esp32.const import VARIANT_ESP32
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -48,6 +50,10 @@ async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32:
# temprature_sens_read() lives in the esp_phy blob, which is excluded by default
include_builtin_idf_component("esp_phy")
if CORE.using_zephyr and CORE.is_nrf52:
zephyr_add_prj_conf("SENSOR", True)
zephyr_add_prj_conf("TEMP_NRF5", True)
+28 -41
View File
@@ -1,87 +1,74 @@
#include "kuntze.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::kuntze {
static const char *const TAG = "kuntze";
static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832};
static constexpr uint16_t REGISTER_PH = 4136;
static constexpr uint16_t REGISTER_TEMPERATURE = 4160;
static constexpr uint16_t REGISTER_DIS1 = 4680;
static constexpr uint16_t REGISTER_DIS2 = 6000;
static constexpr uint16_t REGISTER_REDOX = 4688;
static constexpr uint16_t REGISTER_EC = 4728;
static constexpr uint16_t REGISTER_OCI = 5832;
static constexpr uint16_t REGISTER[] = {REGISTER_PH, REGISTER_TEMPERATURE, REGISTER_DIS1, REGISTER_DIS2,
REGISTER_REDOX, REGISTER_EC, REGISTER_OCI};
// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5)
static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8;
void Kuntze::on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status) || registers.size() < 2)
return;
void Kuntze::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); };
// Each value is a register pair: the reading, then the number of decimal places in its low byte.
float value = registers[0];
for (uint16_t i = 0; i < (registers[1] & 0xFF); i++)
value /= 10.0f;
this->waiting_ = false;
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(KUNTZE_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Data: %s", format_hex_pretty_to(hex_buf, data.data(), data.size()));
float value = (float) get_16bit(0);
for (int i = 0; i < data[3]; i++)
value /= 10.0;
switch (this->state_) {
case 1:
switch (start_address) {
case REGISTER_PH:
ESP_LOGD(TAG, "pH=%.1f", value);
if (this->ph_sensor_ != nullptr)
this->ph_sensor_->publish_state(value);
break;
case 2:
case REGISTER_TEMPERATURE:
ESP_LOGD(TAG, "temperature=%.1f", value);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(value);
break;
case 3:
case REGISTER_DIS1:
ESP_LOGD(TAG, "DIS1=%.1f", value);
if (this->dis1_sensor_ != nullptr)
this->dis1_sensor_->publish_state(value);
break;
case 4:
case REGISTER_DIS2:
ESP_LOGD(TAG, "DIS2=%.1f", value);
if (this->dis2_sensor_ != nullptr)
this->dis2_sensor_->publish_state(value);
break;
case 5:
case REGISTER_REDOX:
ESP_LOGD(TAG, "REDOX=%.1f", value);
if (this->redox_sensor_ != nullptr)
this->redox_sensor_->publish_state(value);
break;
case 6:
case REGISTER_EC:
ESP_LOGD(TAG, "EC=%.1f", value);
if (this->ec_sensor_ != nullptr)
this->ec_sensor_->publish_state(value);
break;
case 7:
case REGISTER_OCI:
ESP_LOGD(TAG, "OCI=%.1f", value);
if (this->oci_sensor_ != nullptr)
this->oci_sensor_->publish_state(value);
break;
}
if (++this->state_ > 7)
this->state_ = 0;
}
void Kuntze::loop() {
uint32_t now = App.get_loop_component_start_time();
// timeout after 15 seconds
if (this->waiting_ && (now - this->last_send_ > 15000)) {
ESP_LOGW(TAG, "timed out waiting for response");
this->waiting_ = false;
}
if (this->waiting_ || (this->state_ == 0))
return;
this->last_send_ = now;
this->read_holding_registers(REGISTER[this->state_ - 1], 2);
this->waiting_ = true;
void Kuntze::update() {
for (uint16_t reg : REGISTER)
this->read_holding_registers(reg, 2);
}
void Kuntze::update() { this->state_ = 1; }
void Kuntze::dump_config() {
ESP_LOGCONFIG(TAG,
"Kuntze:\n"
+2 -6
View File
@@ -18,18 +18,14 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice
void set_ec_sensor(sensor::Sensor *ec_sensor) { ec_sensor_ = ec_sensor; }
void set_oci_sensor(sensor::Sensor *oci_sensor) { oci_sensor_ = oci_sensor; }
void loop() override;
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void dump_config() override;
protected:
int state_{0};
bool waiting_{false};
uint32_t last_send_{0};
sensor::Sensor *ph_sensor_{nullptr};
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *dis1_sensor_{nullptr};
@@ -5,7 +5,11 @@ namespace esphome::light {
uint8_t ESPColorCorrection::gamma_correct_(uint8_t value) const {
if (this->gamma_table_ == nullptr)
return value;
return static_cast<uint8_t>((progmem_read_uint16(&this->gamma_table_[value]) + 128) / 257);
uint16_t table_value = progmem_read_uint16(&this->gamma_table_[value]);
uint8_t result = (table_value + 128) / 257;
if (result == 0 && table_value != 0)
return 1;
return result;
}
uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const {
+2 -1
View File
@@ -483,6 +483,7 @@ LV_ANIM = LvConstant(
LV_GRAD_DIR = LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER")
LV_DITHER = LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF")
LV_GRAD_EXTEND = LvConstant("LV_GRAD_EXTEND_", "PAD", "REPEAT", "REFLECT")
LV_LOG_LEVELS = {
"VERBOSE": "TRACE",
@@ -904,7 +905,7 @@ LV_COLOR_FORMATS = (
LV_DEFINES = (
"LV_USE_FREERTOS_TASK_NOTIFY", "LV_DRAW_BUF_STRIDE_ALIGN", "LV_USE_DRAW_SW", "LV_DRAW_SW_DRAW_UNIT_CNT",
"LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D",
"LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_SW_COMPLEX_GRADIENTS", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D",
"LV_USE_G2D_DRAW_THREAD", "LV_VG_LITE_USE_BOX_SHADOW", "LV_VG_LITE_THORVG_16PIXELS_ALIGN", "LV_LOG_USE_TIMESTAMP",
"LV_LOG_USE_FILE_LINE", "LV_USE_OBJ_ID_BUILTIN", "LV_USE_OBJ_PROPERTY_NAME", "LV_ATTRIBUTE_MEM_ALIGN_SIZE",
"LV_FONT_MONTSERRAT_14", "LV_USE_FONT_PLACEHOLDER", "LV_WIDGETS_HAS_DEFAULT_VALUE", "LV_USE_ARCLABEL",
+172 -23
View File
@@ -13,18 +13,40 @@ from esphome.core import ID
from esphome.cpp_generator import MockObj
from .defines import (
CONF_END_ANGLE,
CONF_GRADIENTS,
CONF_OPA,
CONF_START_ANGLE,
LV_DITHER,
LV_GRAD_EXTEND,
add_define,
add_lv_use,
add_warning,
)
from .lv_validation import lv_color, lv_percentage, opacity
from .lv_validation import (
lv_angle_degrees,
lv_color,
lv_percentage,
opacity,
pixels_or_percent,
)
from .lvcode import lv
from .types import lv_color_t, lv_gradient_t, lv_opa_t
CONF_STOPS = "stops"
CONF_LINEAR = "linear"
CONF_RADIAL = "radial"
CONF_CONICAL = "conical"
CONF_EXTEND = "extend"
CONF_FROM_X = "from_x"
CONF_FROM_Y = "from_y"
CONF_TO_X = "to_x"
CONF_TO_Y = "to_y"
CONF_CENTER_X = "center_x"
CONF_CENTER_Y = "center_y"
CONF_FOCAL_X = "focal_x"
CONF_FOCAL_Y = "focal_y"
CONF_FOCAL_RADIUS = "focal_radius"
def min_stops(value):
@@ -33,27 +55,109 @@ def min_stops(value):
return value
STOPS_SCHEMA = cv.All(
[
cv.Schema(
{
cv.Required(CONF_COLOR): lv_color,
cv.Optional(CONF_OPA, default=1.0): opacity,
cv.Required(CONF_POSITION): lv_percentage,
}
)
],
min_stops,
)
LINEAR_SCHEMA = cv.Schema(
{
cv.Required(CONF_FROM_X): pixels_or_percent,
cv.Required(CONF_FROM_Y): pixels_or_percent,
cv.Required(CONF_TO_X): pixels_or_percent,
cv.Required(CONF_TO_Y): pixels_or_percent,
cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of,
}
)
RADIAL_SCHEMA = cv.Schema(
{
cv.Required(CONF_CENTER_X): pixels_or_percent,
cv.Required(CONF_CENTER_Y): pixels_or_percent,
cv.Required(CONF_TO_X): pixels_or_percent,
cv.Required(CONF_TO_Y): pixels_or_percent,
cv.Optional(CONF_FOCAL_X): pixels_or_percent,
cv.Optional(CONF_FOCAL_Y): pixels_or_percent,
# No default: gradient_validator() must be able to tell whether this was actually
# given, to require it alongside focal_x/focal_y rather than silently drop it.
# LVGL's lv_grad_radial_set_focal() takes this as a scalar, not lv_pct() -
# unlike every other coordinate here, a percentage is not accepted.
cv.Optional(CONF_FOCAL_RADIUS): cv.positive_int,
cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of,
}
)
CONICAL_SCHEMA = cv.Schema(
{
cv.Required(CONF_CENTER_X): pixels_or_percent,
cv.Required(CONF_CENTER_Y): pixels_or_percent,
cv.Optional(CONF_START_ANGLE, default=0): lv_angle_degrees,
cv.Optional(CONF_END_ANGLE, default=360): lv_angle_degrees,
cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of,
}
)
def gradient_validator(config):
direction = config[CONF_DIRECTION]
for gradient_direction, key in (
("LINEAR", CONF_LINEAR),
("RADIAL", CONF_RADIAL),
("CONICAL", CONF_CONICAL),
):
if direction == gradient_direction:
if key not in config:
raise cv.Invalid(
f"'{key}' is required for {gradient_direction} gradient direction"
)
elif key in config:
raise cv.Invalid(
f"'{key}' is only valid with 'direction: {gradient_direction}'"
)
if CONF_RADIAL in config:
radial = config[CONF_RADIAL]
has_focal_x = CONF_FOCAL_X in radial
has_focal_y = CONF_FOCAL_Y in radial
has_focal_radius = CONF_FOCAL_RADIUS in radial
if has_focal_x != has_focal_y or (has_focal_radius and not has_focal_x):
raise cv.Invalid(
"'focal_x', 'focal_y' and 'focal_radius' must be specified together "
"in 'radial'"
)
return config
GRADIENT_SCHEMA = cv.ensure_list(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t),
cv.Required(CONF_DIRECTION): cv.one_of(
"HOR", "HORIZONTAL", "VER", "VERTICAL", upper=True
),
cv.Optional(CONF_DITHER): LV_DITHER.one_of,
cv.Required(CONF_STOPS): cv.All(
[
cv.Schema(
{
cv.Required(CONF_COLOR): lv_color,
cv.Optional(CONF_OPA, default=1.0): opacity,
cv.Required(CONF_POSITION): lv_percentage,
}
)
],
min_stops,
),
}
cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t),
cv.Required(CONF_DIRECTION): cv.one_of(
"HOR",
"HORIZONTAL",
"VER",
"VERTICAL",
"LINEAR",
"RADIAL",
"CONICAL",
upper=True,
),
cv.Optional(CONF_DITHER): LV_DITHER.one_of,
cv.Optional(CONF_LINEAR): LINEAR_SCHEMA,
cv.Optional(CONF_RADIAL): RADIAL_SCHEMA,
cv.Optional(CONF_CONICAL): CONICAL_SCHEMA,
cv.Required(CONF_STOPS): STOPS_SCHEMA,
}
),
gradient_validator,
)
)
@@ -65,15 +169,60 @@ async def gradients_to_code(config):
add_warning(
"The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored"
)
if any(
x[CONF_DIRECTION] in ("LINEAR", "RADIAL", "CONICAL")
for x in config.get(CONF_GRADIENTS, ())
):
# LVGL's software renderer only draws these gradient types when this is enabled; without
# it they silently fall back to a plain horizontal gradient.
add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS")
for gradient in config.get(CONF_GRADIENTS, ()):
var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->")
idbase = gradient[CONF_ID].id
stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION))
max_stops = max(max_stops, len(stops))
if gradient[CONF_DIRECTION].startswith("VER"):
direction = gradient[CONF_DIRECTION]
if direction.startswith("VER"):
lv.grad_vertical_init(var)
else:
elif direction.startswith("HOR"):
lv.grad_horizontal_init(var)
elif direction == "LINEAR":
linear = gradient[CONF_LINEAR]
lv.grad_linear_init(
var,
await pixels_or_percent.process(linear[CONF_FROM_X]),
await pixels_or_percent.process(linear[CONF_FROM_Y]),
await pixels_or_percent.process(linear[CONF_TO_X]),
await pixels_or_percent.process(linear[CONF_TO_Y]),
await LV_GRAD_EXTEND.process(linear[CONF_EXTEND]),
)
elif direction == "RADIAL":
radial = gradient[CONF_RADIAL]
lv.grad_radial_init(
var,
await pixels_or_percent.process(radial[CONF_CENTER_X]),
await pixels_or_percent.process(radial[CONF_CENTER_Y]),
await pixels_or_percent.process(radial[CONF_TO_X]),
await pixels_or_percent.process(radial[CONF_TO_Y]),
await LV_GRAD_EXTEND.process(radial[CONF_EXTEND]),
)
if CONF_FOCAL_X in radial:
lv.grad_radial_set_focal(
var,
await pixels_or_percent.process(radial[CONF_FOCAL_X]),
await pixels_or_percent.process(radial[CONF_FOCAL_Y]),
radial.get(CONF_FOCAL_RADIUS, 0),
)
elif direction == "CONICAL":
conical = gradient[CONF_CONICAL]
lv.grad_conical_init(
var,
await pixels_or_percent.process(conical[CONF_CENTER_X]),
await pixels_or_percent.process(conical[CONF_CENTER_Y]),
await lv_angle_degrees.process(conical[CONF_START_ANGLE]),
await lv_angle_degrees.process(conical[CONF_END_ANGLE]),
await LV_GRAD_EXTEND.process(conical[CONF_EXTEND]),
)
stop_colors = cg.static_const_array(
ID(idbase + "_colors_", type=lv_color_t),
[await lv_color.process(x[CONF_COLOR]) for x in stops],
+12 -2
View File
@@ -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, add_idf_sdkconfig_option
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
import esphome.config_validation as cv
from esphome.const import (
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_PROTOCOL,
CONF_SERVICE,
CONF_SERVICES,
CONF_WIFI,
PlatformFramework,
)
from esphome.core import CORE, Lambda, coroutine_with_priority
@@ -208,7 +209,16 @@ async def to_code(config: ConfigType) -> None:
ethernet.request_ethernet_ip_state_listener()
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.11.3")
add_idf_component(name="espressif/mdns", ref="1.12.0")
# ESPHome only advertises; the browse APIs are unused
add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False)
# The mdns console CLI is never used by ESPHome
add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_CONSOLE_CLI", False)
if CONF_WIFI not in CORE.config:
# Without WiFi the predefined STA/AP interface handlers are dead
# code; disabling them lets mdns build without the WiFi stack.
add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_STA", False)
add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_AP", False)
cg.add_define("USE_MDNS")
@@ -0,0 +1,28 @@
from . import RgbDriverChip
# fmt: off
RgbDriverChip(
"CROWPANEL-ADVANCE-7",
requires={"psram"},
initsequence=(),
pclk_frequency="20MHz",
hsync_pulse_width=4,
hsync_front_porch=8,
hsync_back_porch=8,
vsync_pulse_width=4,
vsync_front_porch=8,
vsync_back_porch=8,
pclk_inverted=True,
color_order="RGB",
width=800,
height=480,
de_pin=42,
hsync_pin=40,
vsync_pin=41,
pclk_pin=39,
data_pins={
"red": [7, 17, 18, 3, 46],
"green": [9, 10, 11, 12, 13, 14],
"blue": [21, 47, 48, 45, 38],
},
)
@@ -0,0 +1,69 @@
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TAG
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@FredM67"]
DEPENDENCIES = ["uart"]
mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter")
Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice)
CONF_MK2PVROUTER_ID = "mk2pvrouter_id"
# Tags are copied into a fixed-size buffer (MAX_TAG_SIZE = 8 in mk2pvrouter.h),
# which needs room for a trailing null terminator.
MAX_TAG_LEN = 7
MK2PVROUTER_LISTENER_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_MK2PVROUTER_ID): cv.use_id(Mk2PVRouter),
cv.Required(CONF_TAG): cv.All(
cv.string_strict, cv.Length(min=1, max=MAX_TAG_LEN), lambda x: x.upper()
),
}
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Mk2PVRouter),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
def final_validate(config: ConfigType) -> None:
# Validate UART settings
schema = uart.final_validate_device_schema(
"mk2pvrouter",
baud_rate=9600,
parity="EVEN",
data_bits=7,
stop_bits=1,
require_rx=True,
require_tx=False,
)
schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
_request_listener_slot = cg.slot_counter("MK2PVROUTER_LISTENER_COUNT")
async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> None:
"""Register a listener with its hub and count it for the compile-time buffer size."""
_request_listener_slot()
cg.add(mk2pvrouter.register_mk2pvrouter_listener(var))
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
@@ -0,0 +1,177 @@
#include "mk2pvrouter.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome::mk2pvrouter {
static const char *const TAG = "mk2pvrouter";
constexpr uint8_t START_FRAME = 0x2;
constexpr uint8_t END_FRAME = 0x3;
constexpr uint8_t LINE_FEED = 0xa;
constexpr uint8_t CARRIAGE_RETURN = 0xd;
constexpr uint8_t TAB = 0x9;
constexpr uint8_t MAX_ITERATIONS = 128;
constexpr uint8_t CRC_MASK = 0x3F;
constexpr uint8_t CRC_OFFSET = 0x20;
// Extracts a TAB-delimited field from [buf_start, buf_end) into dest.
// Returns the field length, or 0 if no TAB was found, or the (uncopied) field
// length if it's >= max_len.
static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) {
const auto *const field_end = static_cast<const char *>(memchr(buf_start, TAB, buf_end - buf_start));
if (!field_end)
return 0;
const size_t len = field_end - buf_start;
if (len >= max_len) {
ESP_LOGE(TAG, "Field too long: %zu bytes (max %zu)", len, max_len);
return len;
}
memcpy(dest, buf_start, len);
dest[len] = '\0'; // Null-terminate
return len;
}
// Calculates the CRC (checksum) for a given group of characters.
uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) {
uint8_t crc_tmp{0};
const auto effective_len = grp_len - CRC_SUFFIX_LEN;
for (size_t i = 0; i < effective_len; i++) {
crc_tmp += grp[i];
}
crc_tmp &= CRC_MASK;
crc_tmp += CRC_OFFSET;
return crc_tmp;
}
// Verifies the CRC of a group against its trailing CRC byte.
bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) {
const auto grp_len = grp_end - grp;
if (grp_len < static_cast<decltype(grp_len)>(CRC_SUFFIX_LEN)) {
ESP_LOGE(TAG, "Empty or too short group");
return false;
}
const auto raw_crc = grp[grp_len - 1];
const auto calculated_crc = this->calculate_crc_(grp, grp_len);
if (raw_crc != calculated_crc) {
ESP_LOGE(TAG, "CRC mismatch: expected %d, got %d", calculated_crc, raw_crc);
return false;
}
return true;
}
// Validates, parses, and publishes a single tag/value group.
void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) {
if (!this->check_crc_(grp, grp_end))
return;
size_t field_len = get_field(this->tag_, grp, grp_end, MAX_TAG_SIZE);
if (!field_len || field_len >= MAX_TAG_SIZE) {
ESP_LOGE(TAG, "Invalid tag");
return;
}
const auto *val_start = grp + field_len + 1; // Skip tag + TAB.
field_len = get_field(this->val_, val_start, grp_end, MAX_VAL_SIZE);
if (!field_len || field_len >= MAX_VAL_SIZE) {
ESP_LOGE(TAG, "Invalid value for tag %s", this->tag_);
return;
}
this->publish_value_(this->tag_, this->val_);
}
// Reads characters until `c` is found or the internal buffer is full.
bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) {
size_t j{0};
while (this->available() > 0 && j++ < MAX_ITERATIONS) {
const auto received = this->read();
if (received < 0)
continue;
if (received == c)
return true;
if (drop)
continue;
if (this->buf_index_ >= (sizeof(this->buf_) - 1)) {
ESP_LOGW(TAG, "Internal buffer full");
this->buf_index_ = 0;
this->state_ = State::WAITING_FOR_START;
return false;
}
this->buf_[this->buf_index_++] = received;
}
return false;
}
void Mk2PVRouter::loop() {
switch (this->state_) {
case State::WAITING_FOR_START:
ESP_LOGVV(TAG, "State: WAITING_FOR_START");
if (this->read_chars_until_(true, START_FRAME))
this->state_ = State::START_FRAME_RECEIVED;
break;
case State::START_FRAME_RECEIVED:
ESP_LOGVV(TAG, "State: START_FRAME_RECEIVED");
if (this->read_chars_until_(false, END_FRAME))
this->state_ = State::END_FRAME_RECEIVED;
break;
case State::END_FRAME_RECEIVED: {
ESP_LOGVV(TAG, "State: END_FRAME_RECEIVED -> processing");
if (this->buf_index_ == 0) {
this->state_ = State::WAITING_FOR_START;
break;
}
auto *buf_finger = this->buf_;
auto *buf_end = this->buf_ + this->buf_index_;
// Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR)
// CRC is computed over "Tag | TAB | Data | TAB".
while ((buf_finger = static_cast<char *>(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) {
++buf_finger; // Skip LF to the start of the group.
auto *const grp_end = static_cast<char *>(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger));
if (!grp_end) {
ESP_LOGE(TAG, "No group found");
break;
}
this->process_group_(buf_finger, grp_end);
buf_finger = grp_end; // grp_end is always < buf_end, so this stays in bounds.
}
this->buf_index_ = 0;
this->state_ = State::WAITING_FOR_START;
break;
}
}
}
void Mk2PVRouter::publish_value_(const char *tag, const char *val) {
#ifdef MK2PVROUTER_LISTENER_COUNT
for (auto *element : this->mk2pvrouter_listeners_) {
if (strcmp(tag, element->get_tag()) != 0)
continue;
element->publish_val(val);
}
#endif
}
void Mk2PVRouter::dump_config() {
ESP_LOGCONFIG(TAG, "Mk2PVRouter:");
this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7);
}
#ifdef MK2PVROUTER_LISTENER_COUNT
void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) {
this->mk2pvrouter_listeners_.push_back(listener);
}
#endif
} // namespace esphome::mk2pvrouter
@@ -0,0 +1,69 @@
#pragma once
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
namespace esphome::mk2pvrouter {
/*
* Buffer sizes based on the mk2pvrouter telemetry protocol, as implemented by the
* firmware's teleinfo.h (see github.com/FredM67/PVRouter-{1,3}-phase):
* - Tags: max 4 chars (S_MC is longest), most are 1-2 chars (P, V1, R2, etc.)
* - Values: max 6 digits signed (-10000), typical 1-5 digits. Energy (E) is a daily
* counter reset at midnight, so it stays well within 6 digits.
* - Frame: STX + multiple lines (LF+tag+TAB+value+TAB+crc+CR) + ETX
* - Line format: \n<tag>\t<value>\t<crc>\r (8-15 bytes per line)
* - Multi-phase with all features: ~150-200 bytes
*/
static constexpr uint8_t MAX_TAG_SIZE = 8; // S_MC (4) + digit (1) + null (1) + margin (2)
static constexpr uint8_t MAX_VAL_SIZE = 8; // -10000 (6) + null (1) + margin (1)
static constexpr uint16_t MAX_BUF_SIZE = 256; // Full frame with all features enabled
// Listener interface for entities that want updates for a specific tag.
class Mk2PVRouterListener {
public:
explicit Mk2PVRouterListener(const char *tag) : tag_(tag) {}
virtual ~Mk2PVRouterListener() = default;
const char *get_tag() const { return this->tag_; }
virtual void publish_val(const char *val) = 0;
protected:
const char *tag_;
};
// Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners.
class Mk2PVRouter final : public Component, public uart::UARTDevice {
public:
#ifdef MK2PVROUTER_LISTENER_COUNT
void register_mk2pvrouter_listener(Mk2PVRouterListener *listener);
#endif
void loop() override;
void dump_config() override;
protected:
static constexpr size_t CRC_SUFFIX_LEN = 1;
static constexpr uint32_t BAUD_RATE = 9600;
enum class State : uint8_t {
WAITING_FOR_START,
START_FRAME_RECEIVED,
END_FRAME_RECEIVED,
};
#ifdef MK2PVROUTER_LISTENER_COUNT
StaticVector<Mk2PVRouterListener *, MK2PVROUTER_LISTENER_COUNT> mk2pvrouter_listeners_;
#endif
uint16_t buf_index_{0};
State state_{State::WAITING_FOR_START};
char tag_[MAX_TAG_SIZE];
char val_[MAX_VAL_SIZE];
char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding
bool read_chars_until_(bool drop, uint8_t c);
uint8_t calculate_crc_(const char *grp, size_t grp_len);
bool check_crc_(const char *grp, const char *grp_end);
void process_group_(const char *grp, const char *grp_end);
void publish_value_(const char *tag, const char *val);
};
} // namespace esphome::mk2pvrouter
@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.const import CONF_ID, CONF_TAG
from esphome.types import ConfigType
from .. import (
CONF_MK2PVROUTER_ID,
MK2PVROUTER_LISTENER_SCHEMA,
mk2pvrouter_ns,
register_mk2pvrouter_listener,
)
Mk2PVRouterSensor = mk2pvrouter_ns.class_(
"Mk2PVRouterSensor", sensor.Sensor, cg.Component
)
CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend(
MK2PVROUTER_LISTENER_SCHEMA
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID])
await register_mk2pvrouter_listener(mk2pvrouter, var)
@@ -0,0 +1,24 @@
#include "mk2pvrouter_sensor.h"
#include "esphome/core/log.h"
namespace esphome::mk2pvrouter {
static const char *const TAG = "mk2pvrouter_sensor";
Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {}
void Mk2PVRouterSensor::publish_val(const char *val) {
auto result = parse_number<float>(val);
if (!result.has_value()) {
ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag());
return;
}
this->publish_state(result.value());
}
void Mk2PVRouterSensor::dump_config() {
LOG_SENSOR(" ", "Mk2PVRouter Sensor", this);
ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag());
}
} // namespace esphome::mk2pvrouter
@@ -0,0 +1,15 @@
#pragma once
#include "esphome/components/mk2pvrouter/mk2pvrouter.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::mk2pvrouter {
class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component {
public:
explicit Mk2PVRouterSensor(const char *tag);
void publish_val(const char *val) override;
void dump_config() override;
};
} // namespace esphome::mk2pvrouter
+2 -3
View File
@@ -89,9 +89,8 @@ _WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
def is_function_code_write(function_code: int) -> bool:
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
so an exception-flagged code still classifies by its base code - stricter than the runtime hub,
whose classify() treats an exception-flagged code as a read. Keep in sync with
modbus::helpers::is_function_code_write()."""
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
return function_code & 0x7F in _WRITE_FUNCTION_CODES
+139 -130
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -10,43 +11,55 @@ namespace esphome::modbus {
static const char *const TAG = "modbus";
// Maximum bytes to log for Modbus frames (truncated if larger)
static constexpr size_t MODBUS_MAX_LOG_BYTES = 64;
// Approximate bits per character on the wire (depends on parity/stop bit config)
static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11;
// Milliseconds per second
static constexpr uint32_t MS_PER_SEC = 1000;
static constexpr uint32_t US_PER_SEC = 1000000;
static constexpr uint32_t US_PER_MS = 1000;
// Shortest gap between two "no device accepted broadcast" warnings
static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC;
// Minimum interframe delay per the Modbus spec (fixed 1750us above 19200 baud)
static constexpr uint32_t MODBUS_MIN_FRAME_DELAY_US = 1750;
// Diagnostics only: the backdated byte stamp can precede last_send_ (echo, or noise during our own
// send), where an unsigned wrap would print ~4.29e9.
static uint32_t us_since_send(uint32_t last_modbus_byte, uint32_t last_send) {
const uint32_t elapsed = last_modbus_byte - last_send;
return (int32_t) elapsed < 0 ? 0 : elapsed;
}
void Modbus::setup() {
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->setup();
}
this->frame_delay_ms_ =
std::max(2, // 1750us minimum per spec - rounded up to 2ms.
// 3.5 characters * 11 bits per character * 1000ms/sec / (bits/sec) (Standard modbus frame delay)
(uint16_t) (3.5 * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1);
// RTU specifies 11 bits per character but 8N1 is 10, so derive it from the framing. The schema
// forbids a zero, so one here means the hub never set it (weikai): fall back to 8N1 and a 1 baud floor.
const uint8_t data_bits = this->parent_->get_data_bits() != 0 ? this->parent_->get_data_bits() : 8;
const uint8_t stop_bits = this->parent_->get_stop_bits() != 0 ? this->parent_->get_stop_bits() : 1;
const uint32_t baud_rate = std::max<uint32_t>(1u, this->parent_->get_baud_rate());
this->bits_per_char_ = static_cast<uint8_t>(
1 + data_bits + (this->parent_->get_parity() == uart::UART_CONFIG_PARITY_NONE ? 0 : 1) + stop_bits);
// 3.5 characters * bits per character * 1e6 us/sec / (bits/sec) (Standard modbus frame delay)
this->frame_delay_us_ =
std::max(MODBUS_MIN_FRAME_DELAY_US, (uint32_t) (3.5 * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1);
// When rx_full_threshold is configured (non-zero), the UART has a hardware FIFO with a
// meaningful threshold (e.g., ESP32 native UART), so we can calculate a precise delay.
// Otherwise (e.g., USB UART), use 50ms to handle data arriving in chunks.
static constexpr uint16_t DEFAULT_LONG_RX_BUFFER_DELAY_MS = 50;
static constexpr uint32_t DEFAULT_LONG_RX_BUFFER_DELAY_US = 50 * US_PER_MS;
size_t rx_threshold = this->parent_->get_rx_full_threshold();
this->long_rx_buffer_delay_ms_ =
rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET
? (rx_threshold * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1
: DEFAULT_LONG_RX_BUFFER_DELAY_MS;
this->long_rx_buffer_delay_us_ = rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET
? (uint32_t) (rx_threshold * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1
: DEFAULT_LONG_RX_BUFFER_DELAY_US;
// The idle-timeout interrupt fires rx_timeout characters after the last byte, so that much silence
// has already passed by the time we read it: backdate so the gap measures silence on the wire.
this->rx_detect_latency_us_ =
(uint32_t) (this->parent_->get_rx_timeout() * this->bits_per_char_ * US_PER_SEC / baud_rate);
}
void Modbus::loop() {
// Receive any available bytes from UART
this->receive_bytes_();
// Parse bytes into frames and process them
this->parse_modbus_frames();
}
@@ -55,12 +68,12 @@ void ModbusClientHub::loop() {
// never times out an entry whose pending count has not been drained. No-op when nothing is owed.
this->sweep_();
this->Modbus::loop(); // receive bytes and parse frames
this->Modbus::loop();
// Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the
// entry up and holds off if the response has started arriving.
if (this->waiting_for_response_ &&
this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) {
this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_us_) {
this->expire_waiting_();
}
@@ -80,7 +93,7 @@ void ModbusClientHub::expire_waiting_() {
}
// Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected).
if (cmd->state == FrameState::WAITING) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(),
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "us after last send", cmd->frame.address(),
this->last_receive_check_ - this->last_send_);
}
// Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry
@@ -94,52 +107,50 @@ void ModbusClientHub::expire_waiting_() {
bool Modbus::timeout_() {
// If the response frame is finished (including interframe delay) - we timeout.
// The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts
// when the buffer is filling the back half of the response
const uint16_t timeout = std::max(
(uint16_t) this->frame_delay_ms_,
(uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_
: 0));
// when the buffer is filling the back half of the response. The latch decides, not the current size:
// parsing a leading frame can shrink the buffer below the threshold while the rest is still streaming.
// The latency term covers the final batch, which is idle-delivered.
const uint32_t timeout =
this->exceeded_rx_full_threshold_
? std::max(this->frame_delay_us_, this->long_rx_buffer_delay_us_ + this->rx_detect_latency_us_)
: this->frame_delay_us_;
return this->last_receive_check_ - this->last_modbus_byte_ > timeout;
}
// We use micros() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time
// If we use a cached value in place of micros() and last_modbus_byte_ is updated inside our loop
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
// So in this component we don't use any cached timestamp values to avoid these annoying bugs.
// Compare before subtracting: a signed difference would read a bus idle past half the micros() wrap
// (~35 min) as a huge delay still owed.
static inline uint32_t remaining_delay(uint32_t elapsed, uint32_t required) {
return elapsed >= required ? 0 : required - elapsed;
}
int32_t Modbus::tx_delay_remaining() {
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time
// If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
// So in this component we don't use any cached timestamp values to avoid these annoying bugs
const uint32_t now = millis();
return std::max({(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)),
(int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))});
const uint32_t now = micros();
return (int32_t) std::max(remaining_delay(now - this->last_send_, this->last_send_tx_offset_ + this->frame_delay_us_),
remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_));
}
int32_t ModbusClientHub::tx_delay_remaining() {
const uint32_t now = millis();
return std::max({(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ -
(now - this->last_send_)),
(int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))});
const uint32_t now = micros();
return (int32_t) std::max(
remaining_delay(now - this->last_send_,
this->last_send_tx_offset_ + this->frame_delay_us_ + this->turnaround_delay_us_),
remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_ + this->turnaround_delay_us_));
}
bool Modbus::tx_blocked() {
// We block transmission in any of these cases:
// 1. There are bytes in the UART Rx buffer
// 2. There are bytes in our Rx buffer
// 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
// 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
// N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by
// send_frame_.
return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS;
// Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction
// (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to
// MODBUS_TX_MAX_DELAY_US doesn't block - send_frame_ absorbs it instead of looping on small waits.
return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_US;
}
bool ModbusClientHub::tx_blocked() {
// We block transmission in any of these case:
// 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED)
// 2. Any of the base class tx_blocked conditions
return this->waiting_for_response_ || this->Modbus::tx_blocked();
}
bool ModbusClientHub::tx_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); }
bool ModbusClientHub::tx_buffer_empty() {
// "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in
@@ -153,20 +164,26 @@ bool ModbusClientHub::tx_buffer_empty() {
}
void Modbus::receive_bytes_() {
this->last_receive_check_ = millis();
this->last_receive_check_ = micros();
size_t bytes = this->available();
if (bytes) {
size_t buffer_size = this->rx_buffer_.size();
this->last_modbus_byte_ = this->last_receive_check_;
// Below the threshold the batch can only be idle-delivered, so its last byte finished one detection
// latency ago; at or above it the frame may still be streaming, so stamp now.
this->last_modbus_byte_ = bytes < this->parent_->get_rx_full_threshold()
? this->last_receive_check_ - this->rx_detect_latency_us_
: this->last_receive_check_;
this->rx_buffer_.resize(buffer_size + bytes);
if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) {
this->rx_buffer_.resize(buffer_size);
return;
}
if (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold())
this->exceeded_rx_full_threshold_ = true;
if (buffer_size == 0) {
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send",
this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_);
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "us after last send",
this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), micros() - this->last_send_);
}
}
}
@@ -219,10 +236,9 @@ void ModbusServerHub::parse_modbus_frames() {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
// Scans forward from min_length to find a frame boundary by CRC match for unknown-length function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
// could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
@@ -320,8 +336,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
ModbusDeviceCommand *cmd = this->waiting_for_response_ ? this->find_waiting_() : nullptr;
if (cmd == nullptr) {
ESP_LOGW(TAG,
"Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send",
address, function_code, this->last_modbus_byte_ - this->last_send_);
"Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "us after last send",
address, function_code, us_since_send(this->last_modbus_byte_, this->last_send_));
return;
}
@@ -331,9 +347,9 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
ESP_LOGW(TAG,
"Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32
"ms after last send",
"us after last send",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
us_since_send(this->last_modbus_byte_, this->last_send_));
// Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this
// transaction and blocks tx until the send-wait timeout, where it gets its on_no_response.
cmd->interrupt();
@@ -346,8 +362,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
// cleared-interrupted frame still ends in on_no_response rather than delivering a late response.
ESP_LOGW(TAG,
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
"ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
"us after last send",
address, us_since_send(this->last_modbus_byte_, this->last_send_));
return;
}
@@ -358,12 +374,12 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
this->sweep_needed_ = true;
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present
ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send",
function_code, exception, address, this->last_modbus_byte_ - this->last_send_);
ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "us after last send",
function_code, exception, address, us_since_send(this->last_modbus_byte_, this->last_send_));
cmd->error(static_cast<ExceptionCode>(exception));
} else if (!cmd->response(pdu)) {
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address,
this->last_modbus_byte_ - this->last_send_);
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address,
us_since_send(this->last_modbus_byte_, this->last_send_));
}
}
@@ -531,8 +547,7 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
return;
}
// A broadcast is never answered, so a rejecting device has no other feedback channel: report the
// per-device outcome at V, and warn if the write reached nobody at all.
bool accepted = false;
// per-device outcome at V.
for (auto *device : this->devices_) {
// Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need
// to: the hub owns the difference, which is only that no reply is ever sent.
@@ -542,24 +557,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
if (device_status.has_value()) {
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
static_cast<uint8_t>(device_status.value()));
} else {
accepted = true;
}
}
if (!accepted && !this->devices_.empty()) {
const uint16_t entity_count = coils ? coil_count : static_cast<uint16_t>(registers.size());
const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers");
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
// repeats forever, so warning per frame would flood the log.
const uint32_t now = millis();
if (this->last_unaccepted_broadcast_warn_ == 0 ||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
this->last_unaccepted_broadcast_warn_ = now;
ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
} else {
ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
}
}
}
@@ -778,13 +775,18 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
// Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check
// after it and refuse (return false) if a byte arrived in that window rather than transmit over it.
bool Modbus::send_frame_(const ModbusFrame &frame) {
const int32_t tx_delay_remaining = this->tx_delay_remaining();
int32_t tx_delay_remaining = this->tx_delay_remaining();
if (tx_delay_remaining > 0) {
delay(tx_delay_remaining);
// Yield the whole-ms part: delay() never blocks past the request on FreeRTOS, and only slightly
// over elsewhere, which just lengthens the gap. The recompute below makes the remainder exact.
if (tx_delay_remaining >= (int32_t) US_PER_MS) {
delay(tx_delay_remaining / US_PER_MS);
tx_delay_remaining = this->tx_delay_remaining();
}
if (tx_delay_remaining > 0)
delayMicroseconds(tx_delay_remaining);
}
// The delay above can span several ms; a byte arriving in that window blocks transmission after the
// caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry.
if (this->tx_blocked()) {
return false;
}
@@ -797,14 +799,15 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
this->last_send_tx_offset_ = 0;
} else {
this->write_array(frame.data.data(), frame.size());
this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1;
this->last_send_tx_offset_ =
frame.size() * this->bits_per_char_ * US_PER_SEC / std::max<uint32_t>(1u, this->parent_->get_baud_rate()) + 1;
}
uint32_t now = millis();
uint32_t now = micros();
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive",
ESP_LOGV(TAG, "Write: %s %" PRIu32 "us after last send, %" PRIu32 "us after last receive",
format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_,
now - this->last_modbus_byte_);
this->last_send_ = now;
@@ -812,6 +815,9 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
}
void ModbusClientHub::send_next_frame_() {
if (this->tx_buffer_.empty())
return;
if (this->tx_blocked())
return;
@@ -831,7 +837,7 @@ void ModbusClientHub::send_next_frame_() {
// reports the transmission, and the entry then retires with no terminal callback instead of
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
// spaces the next frame; the following sweep erases the entry.
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)");
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
cmd->complete_broadcast();
this->sweep_needed_ = true;
return;
@@ -842,20 +848,25 @@ void ModbusClientHub::send_next_frame_() {
void ModbusClientHub::dump_config() {
ESP_LOGCONFIG(TAG,
"Modbus:\n"
" Send Wait Time: %" PRIu16 " ms\n"
" Turnaround Time: %" PRIu16 " ms\n"
" Frame Delay: %" PRIu16 " ms\n"
" Long Rx Buffer Delay: %" PRIu16 " ms",
this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_,
this->long_rx_buffer_delay_ms_);
" Send Wait Time: %" PRIu32 " ms\n"
" Turnaround Time: %" PRIu32 " ms\n"
" Frame Delay: %" PRIu32 " us\n"
" Long Rx Buffer Delay: %" PRIu32 " us\n"
" Bits Per Character: %" PRIu8 "\n"
" Rx Detect Latency: %" PRIu32 " us",
this->send_wait_time_us_ / US_PER_MS, this->turnaround_delay_us_ / US_PER_MS, this->frame_delay_us_,
this->long_rx_buffer_delay_us_, this->bits_per_char_, this->rx_detect_latency_us_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
}
void ModbusServerHub::dump_config() {
ESP_LOGCONFIG(TAG,
"Modbus:\n"
" Frame Delay: %" PRIu16 " ms\n"
" Long Rx Buffer Delay: %" PRIu16 " ms",
this->frame_delay_ms_, this->long_rx_buffer_delay_ms_);
" Frame Delay: %" PRIu32 " us\n"
" Long Rx Buffer Delay: %" PRIu32 " us\n"
" Bits Per Character: %" PRIu8 "\n"
" Rx Detect Latency: %" PRIu32 " us",
this->frame_delay_us_, this->long_rx_buffer_delay_us_, this->bits_per_char_,
this->rx_detect_latency_us_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
}
@@ -983,6 +994,8 @@ bool ModbusDeviceCommand::timed_out() {
this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1)
if (this->device == nullptr)
return false; // resolved, no one to tell
// A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may
// call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner.
if (this->device->on_no_response(this->frame.pdu()))
this->increment_pending(); // granted retry = re-request (capped)
return true;
@@ -1054,18 +1067,14 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size());
return false;
}
// classify() drives both the broadcast guard and the continuous check below; compute it once.
const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]);
// A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that
// changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code -
// as it could never deliver a result, so the caller learns via the false return (and on_not_sent).
// 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half
// lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom
// code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly
// here to match classify()'s exception-first handling of the write side.
if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE &&
(!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) {
if (helpers::is_function_code_exception(pdu[0])) {
ESP_LOGW(TAG, "Exception PDU refused for address %" PRIu8 ": function code 0x%X has the exception bit set", address,
pdu[0]);
return false;
}
if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
return false;
}
@@ -1073,7 +1082,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
// merged below carries effective options, never the raw request.
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
if (options.continuous && priority == CommandPriority::WRITE) {
if (options.continuous && helpers::is_function_code_write(pdu[0])) {
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
options.continuous = false;
}
@@ -1089,9 +1098,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
continue;
if (device == nullptr) {
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
const bool requeueable =
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
if (requeueable) {
if (helpers::is_function_code_read_only(pdu[0])) {
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
} else {
ESP_LOGW(TAG,
@@ -1188,7 +1195,8 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
// without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices.
std::memcpy(this->deferred_payload_.data(), payload, len);
this->deferred_payload_len_ = len;
this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() {
// set_timeout() takes milliseconds; round the microsecond delay up so we never fire early.
this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
if (!this->send_frame_(frame))
@@ -1208,11 +1216,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t
bytes = bytes_to_clear;
if (bytes > 0) {
if (warn) {
ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_);
ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason),
micros() - this->last_send_);
} else {
ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_);
ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason),
micros() - this->last_send_);
}
if (bytes == this->rx_buffer_.size()) {
this->rx_buffer_.clear();
@@ -1220,6 +1228,8 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t
this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes);
}
}
if (this->rx_buffer_.empty())
this->exceeded_rx_full_threshold_ = false;
}
void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
@@ -1364,7 +1374,6 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
}
}
// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response
void ModbusClientDevice::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) {
// The dispatcher never calls this with an empty request, but this is a public virtual - stay safe.
+85 -153
View File
@@ -16,26 +16,21 @@
namespace esphome::modbus {
// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a
// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a
// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing.
// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus
// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one
// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266.
// Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames
// (e.g. a loop writing a changing value) could grow the heap unboundedly.
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_US = 5000;
// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes
// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation.
// (address + 5-byte PDU + 2-byte CRC).
static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8;
struct ModbusFrame {
// Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger
// multi-register or custom frames spill to a single heap allocation. This keeps the common,
// high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn.
// The buffer tracks its own length, so no separate size field is needed.
SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data; // Modbus RTU max is 256 bytes
// Small-buffer-optimized: typical frames fit inline, keeping high-frequency tx traffic off the
// heap; only large multi-register or custom frames spill to a single heap allocation.
SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data;
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) {
uint8_t *buf = this->data.init(pdu_len + 3);
buf[0] = address;
@@ -46,12 +41,9 @@ struct ModbusFrame {
}
uint16_t size() const { return static_cast<uint16_t>(this->data.size()); }
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
uint8_t address() const { return this->data.data()[0]; }
/// The PDU: function code + data, without address or CRC. Only valid while the frame is alive.
/// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the
/// subtraction would wrap on anything shorter.
/// A PDU is [function code][data...] without address or CRC. Only valid while the frame is alive.
/// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors)
std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); }
};
@@ -73,23 +65,23 @@ class Modbus : public uart::UARTDevice, public Component {
virtual int32_t tx_delay_remaining();
virtual void parse_modbus_frames() = 0;
bool parse_modbus_server_frame_();
// pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code.
virtual void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) = 0;
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
// Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms,
// so this re-checks after the delay and returns false without transmitting if a byte arrived in that
// window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted.
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
// All timestamps and durations below are micros()-based
uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
uint32_t last_send_{0};
uint32_t last_send_tx_offset_{0};
uint16_t frame_delay_ms_{5};
uint16_t long_rx_buffer_delay_ms_{0};
uint32_t frame_delay_us_{5000};
uint32_t long_rx_buffer_delay_us_{0};
uint32_t rx_detect_latency_us_{0};
// Bits on the wire per character (start + data + optional parity + stop); 12 at most.
uint8_t bits_per_char_{11};
// Latched when a read reaches rx_full_threshold, cleared when the buffer drains.
bool exceeded_rx_full_threshold_{false};
GPIOPin *flow_control_pin_{nullptr};
@@ -99,8 +91,7 @@ class Modbus : public uart::UARTDevice, public Component {
class ModbusClientDevice;
class ModbusServerDevice;
// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived
// at selection time, never caller-chosen or stored.
// Transmit ordering, highest first: writes before one-shot reads before continuous polls.
enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE };
// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed
@@ -112,20 +103,15 @@ enum class FrameState : uint8_t {
RECEIVED_EXCEPTION,
TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase
INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout
WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal
INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response
RETIRED, // cleared, off the wire
WAITING_RETIRED, // retired while WAITING: a late response is still delivered as its usual terminal
INTERRUPTED_RETIRED, // retired while INTERRUPTED: still distrusts late frames, ends in on_no_response
RETIRED, // retired, off the wire
};
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
// but it arrives inert. Every new field must define three rules before it does anything:
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
// stripped for mutating codes),
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
// to erase the entry.
// A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
// retire()/silent_retire().
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
@@ -135,17 +121,13 @@ struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
// fairness within a class. Meant to wrap.
uint16_t seq{0};
FrameState state{FrameState::READY};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
uint8_t pending{1};
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
// CommandOptions comment for the rules a new field must define.
// The entry's LIVE effective options, not a record of the caller's request
CommandOptions options;
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
@@ -154,28 +136,22 @@ struct ModbusDeviceCommand {
CommandOptions options = {}, uint16_t seq = 0)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const {
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
}
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
static CommandPriority classify(uint8_t function_code) {
if (helpers::is_function_code_exception(function_code))
return CommandPriority::READ;
if (helpers::is_function_code_write(function_code)) {
if (this->options.continuous)
return CommandPriority::CONTINUOUS;
if (helpers::is_function_code_write(this->frame.pdu()[0])) {
return CommandPriority::WRITE;
}
return CommandPriority::READ;
}
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
// Requests this entry can serve
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
return (requeueable && !this->options.continuous) ? 2 : 1;
return (helpers::is_function_code_read_only(fc) && !this->options.continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
// Device-scoped clear: detach with no callback. An entry still waiting for a response keeps its state as a
// reply-ignoring shell that resolves silently; any other goes RETIRED.
void silent_retire() {
if (!this->waiting_state())
this->state = FrameState::RETIRED;
@@ -183,28 +159,18 @@ struct ModbusDeviceCommand {
this->device = nullptr;
}
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal
// callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing.
// A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such
// code caps pending at 1, so pending is always 1 here - clear it.
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
void complete_broadcast() {
this->state = FrameState::RETIRED;
this->pending = 0;
}
// Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++).
// Re-ready for another transmission, restamped to the tail of its class
void requeue(uint16_t seq) {
this->state = FrameState::READY;
this->seq = seq;
}
// Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to
// a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter -
// to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed.
// On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the
// single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run
// once to serve that request - so restore one first. While the flag is still set max_pending() is 1,
// so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op
// on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without
// retroactively inflating that no-op.
// a one-shot.
void make_continuous(bool continuous) {
if (continuous) {
this->options.continuous = true;
@@ -214,13 +180,9 @@ struct ModbusDeviceCommand {
this->options.continuous = false;
}
}
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-delivered
// request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is
// still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED ->
// INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other
// state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry:
// the clear is address-scoped (any device may call it) while the retry is the owning device's call
// via on_no_response - the bus obeys the owner.
// still coming) and drains only its duplicates.
void retire() {
if (this->state == FrameState::WAITING) {
this->state = FrameState::WAITING_RETIRED;
@@ -229,10 +191,10 @@ struct ModbusDeviceCommand {
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->options = {}; // reset every option so a future field is torn down without editing here
this->options = {}; // reset every option
}
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
// True while the entry is still waiting for a response
bool waiting_state() const {
return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED ||
this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED;
@@ -245,7 +207,7 @@ struct ModbusDeviceCommand {
}
return false;
}
// Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry).
bool increment_pending() {
if (this->pending < this->max_pending()) {
this->pending++;
@@ -255,7 +217,7 @@ struct ModbusDeviceCommand {
}
// Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and
// returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here.
// returns whether a callback ran.
bool sent();
bool response(std::span<const uint8_t> response_pdu);
bool error(ExceptionCode exception_code);
@@ -264,9 +226,6 @@ struct ModbusDeviceCommand {
bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one.
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
/// cancel built from different argument values will not reach the polls it does not byte-match.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
@@ -279,8 +238,9 @@ class ModbusClientHub : public Modbus {
ModbusClientHub() = default;
void dump_config() override;
void loop() override;
void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; }
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
// Config arrives in milliseconds; stored internally in microseconds like all other timing.
void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_us_ = time_in_ms * 1000UL; }
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; }
bool tx_buffer_empty();
bool tx_blocked() override;
ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
@@ -291,17 +251,13 @@ class ModbusClientHub : public Modbus {
payload_len),
device);
};
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in
/// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets
/// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
/// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast,
/// address 0, gets only on_sent()). False = refused, and no callback of any kind follows.
/// Neither means anything reached the wire - on_sent() reports that.
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {});
// Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions:
// the bool return and the options argument arrived after that release, so nothing external can be
// relying on them under this name. Callers who want the queued/refused answer move to queue_pdu().
// Remove before 2027.2.0. Deliberately the void, no-options signature 2026.7.4 shipped: nothing
// external can rely on the later additions under this name.
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
"reports whether the request was accepted. Removed in 2027.2.0",
"2026.8.0")
@@ -310,9 +266,10 @@ class ModbusClientHub : public Modbus {
}
ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently.
// Clear all commands matching the given address; each unsent request resolves via on_not_sent(), but a
// frame on the wire still runs to its usual terminal.
void clear_tx_queue_for_address(uint8_t address);
// Clear all commands for a given device; no callbacks are delivered.
void clear_tx_queue_for_device(ModbusClientDevice *device);
protected:
@@ -322,16 +279,15 @@ class ModbusClientHub : public Modbus {
void send_next_frame_();
// Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState.
void sweep_();
// The selection function: best READY entry (WRITE class first, then one-shot reads, then the
// least-recently-served continuous; FIFO by seq within each group), or nullptr.
// The selection function: best READY entry (ordered by priority; FIFO by seq within each group), or nullptr.
ModbusDeviceCommand *select_next_ready_();
// Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED).
ModbusDeviceCommand *find_waiting_();
// End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState.
void expire_waiting_();
uint16_t send_wait_time_{2000};
uint16_t turnaround_delay_ms_{0};
uint32_t send_wait_time_us_{2000000};
uint32_t turnaround_delay_us_{0};
// Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select
// while it is set, so at most one frame is awaiting a response.
@@ -349,13 +305,10 @@ class ModbusClientHub : public Modbus {
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
/// True when a transaction carried no exception. The optional holds the exception, so has_value() means
/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the
/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code
/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer.
/// True when a transaction carried no exception.
inline bool succeeded(ResponseStatus status) { return !status.has_value(); }
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// Register values exchanged with server handlers, in address order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
@@ -373,59 +326,46 @@ class ModbusServerHub : public Modbus {
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
// writes (which silently drop invalid frames).
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the address order register
// values, validating the register count and address range. Shared by unicast and broadcast writes.
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues &registers);
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers);
// Appends the big-endian register values in values to registers, in host byte order.
// Assembles host-order registers from the big-endian bytes in values and appends them to registers.
void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers);
ModbusServerDevice *find_device_(uint8_t address);
// Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space,
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast
// write is never answered, so the check cannot send it itself. Shared by the register and
// coil/discrete-input handlers, which all address the same 16-bit space.
// Returns std::nullopt if [start_address, start_address + count) fits in a 16-bit address space, otherwise
// ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. Shared by the
// register/coil/discrete-input handlers, which all use a 16-bit address space.
ResponseStatus check_address_range_(uint16_t start_address, uint16_t count);
// Parses a read request PDU (start address(2) + quantity(2)), shared by the register and
// coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the
// function code; entity_name only labels the rejection log.
// Parses read request data. max_entities is the protocol ceiling for the function code; entity_name labels
// the rejection log.
ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name,
uint16_t &start_address, uint16_t &count);
// Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed
// bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take.
// Parses single-coil write data
ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value);
// Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive
// buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and
// broadcast paths so the two validate identically.
// Parses write-multiple-coil data into a packed-bit view pointing straight into the receive buffer, so the
// coil values are never copied.
ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count,
std::span<const uint8_t> &packed_bytes);
// Builds the body of a register read response (byte count followed by the big-endian register values) into
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
// identical across them. Returns false once an exception has been sent: the one the handler reported via
// status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the
// protocol read limit, or the body does not fit.
// Builds the body of a register read response into response_buffer. Returns false once an exception has
// been sent: the one the handler reported via status, or SERVICE_DEVICE_FAILURE if it returned the wrong
// number of registers, the count exceeds the protocol read limit, or the body does not fit.
bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len);
void send_raw_(const uint8_t *payload, uint16_t len);
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
// Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart.
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting
// on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries.
uint32_t last_unaccepted_broadcast_warn_{0};
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
@@ -555,10 +495,7 @@ class ModbusClientDevice {
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
this);
}
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will
/// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()),
/// false = refused at the door and nothing further happens. Neither means the frame is on the wire;
/// on_sent() reports that.
/// See ModbusClientHub::queue_pdu() for the return contract.
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
return this->parent_->queue_pdu(this->address_, pdu, this, options);
}
@@ -573,11 +510,8 @@ class ModbusClientDevice {
return; // too short to contain a PDU; refused at the door like any invalid send
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
// The typed request builders below all queue through queue_pdu(), so they share its contract: true
// means the request is queued and will resolve in exactly one terminal callback (except a broadcast
// (address 0), which is never answered and so gets only on_sent()), false means it was refused outright
// with no callback. Neither says the frame has been transmitted - on_sent() does.
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
// The typed request builders below all queue through queue_pdu() and share its return contract.
// Reads use the table-appropriate function code; an unreadable entity type maps to INVALID, which
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
CommandOptions options = {}) {
@@ -607,6 +541,9 @@ class ModbusClientDevice {
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
}
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
// Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's.
if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS)
return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values));
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
}
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
@@ -619,11 +556,9 @@ class ModbusClientDevice {
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the
/// read registers, the same wire shape as a holding-register read). A device exception - typically a
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status,
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
/// needs to also override on_error().
/// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
/// (typically a rejected write half) arrives there too via its status - one callback handles both
/// outcomes with no on_error() override needed.
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
std::span<const uint16_t> write_values) {
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
@@ -644,12 +579,9 @@ class ModbusClientDevice {
bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE
};
// Compatibility shim for external components written against the pre-2026.8 API, which subclassed
// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree
// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old
// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy
// exists only on this deprecated path) and on_modbus_error() the function code and exception code.
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0)
// Compatibility shim adapting the span-based hooks back to the pre-2026.8 on_modbus_data()/
// on_modbus_error() signatures (the owning-vector heap copy exists only on this deprecated path).
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0).
class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0",
"2026.8.0") ModbusDevice : public ModbusClientDevice {
public:
@@ -47,7 +47,6 @@ enum class FunctionCode : uint8_t {
using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0",
"2026.8.0") = FunctionCode;
/*Allow direct comparison operators between FunctionCode and uint8_t*/
inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
@@ -117,6 +116,9 @@ static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) =
static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
// A multiple-write PDU starts with function code(1) + start address(2) + quantity(2) + byte count(1),
// followed by two bytes per register.
static constexpr uint16_t WRITE_MULTIPLE_HEADER_SIZE = 6;
static constexpr uint16_t MAX_FRAME_SIZE = 256;
// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered.
+67 -23
View File
@@ -30,9 +30,11 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
// function(1) + byte count(1) + packed coil bytes
return 2 + (size > 1 ? std::min(frame[1], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ))) : 0);
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
// function(1) + byte count(1) + data
// function(1) + byte count(1) + register data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
@@ -60,6 +62,9 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
if (size < MIN_PDU_SIZE)
return MIN_PDU_SIZE;
if (is_function_code_exception(frame[0])) {
return 2; // never a valid request; sized like the exception reply so the CRC fails at once
}
switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
@@ -287,25 +292,52 @@ std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, Senso
}
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) {
const size_t required_size = required_payload_size(sensor_value_type);
if (required_size == 0) {
return 0; // RAW/unsupported: nothing to read
// RAW and BIT carry no fixed-width number, so there is nothing to decode whatever the span holds.
// register_width_for() reports 1 for them, so this must be checked before the width test below.
if (sensor_value_type == SensorValueType::RAW || sensor_value_type == SensorValueType::BIT) {
return 0;
}
const size_t required_words = required_size / 2;
const uint16_t required_words = register_width_for(sensor_value_type);
if (required_words > count) {
ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), count, required_words);
ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%u",
static_cast<unsigned int>(sensor_value_type), count, static_cast<unsigned int>(required_words));
return std::nullopt;
}
// Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the
// sign-extension behaviour stays identical to the wire path.
uint8_t bytes[8]; // at most 4 registers (QWORD)
for (size_t i = 0; i < required_words; i++) {
uint16_t reg = registers[i];
bytes[i * 2] = static_cast<uint8_t>(reg >> 8);
bytes[i * 2 + 1] = static_cast<uint8_t>(reg & 0xFF);
// Registers are the wire's own unit, so decode them directly rather than serializing back to bytes.
// Each case defers to registers_to_value() so the word order and sign rules have one definition, with
// two deliberate exceptions matching what the byte decoder returned: the float types yield their bit
// pattern rather than a float, and U_QWORD shares the signed branch because the return type is int64_t.
switch (sensor_value_type) {
case SensorValueType::U_WORD:
return registers_to_value<SensorValueType::U_WORD>(registers);
case SensorValueType::U_WORD_S:
return registers_to_value<SensorValueType::U_WORD_S>(registers);
case SensorValueType::S_WORD:
return registers_to_value<SensorValueType::S_WORD>(registers);
case SensorValueType::S_WORD_S:
return registers_to_value<SensorValueType::S_WORD_S>(registers);
case SensorValueType::U_DWORD:
return registers_to_value<SensorValueType::U_DWORD>(registers);
case SensorValueType::U_DWORD_R:
return registers_to_value<SensorValueType::U_DWORD_R>(registers);
case SensorValueType::S_DWORD:
return registers_to_value<SensorValueType::S_DWORD>(registers);
case SensorValueType::S_DWORD_R:
return registers_to_value<SensorValueType::S_DWORD_R>(registers);
case SensorValueType::FP32:
return registers_to_uint32(registers[0], registers[1]);
case SensorValueType::FP32_R:
return registers_to_uint32(registers[1], registers[0]);
// Signed for both: an unsigned QWORD above INT64_MAX has to come back as a negative int64_t.
case SensorValueType::U_QWORD:
case SensorValueType::S_QWORD:
return registers_to_value<SensorValueType::S_QWORD>(registers);
case SensorValueType::U_QWORD_R:
case SensorValueType::S_QWORD_R:
return registers_to_value<SensorValueType::S_QWORD_R>(registers);
default:
return 0;
}
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
// Append a 16-bit value to a PDU in big-endian (wire) byte order.
@@ -381,8 +413,6 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities,
const uint8_t *values, size_t values_len) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
if (is_function_code_read_only(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
@@ -445,9 +475,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
return pdu;
}
// The quantity is spec-bounded above, so the data length just has to agree with it exactly
// (registers are 2 bytes each, coils pack 8 per byte). This is the same consistency the response
// dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified
// non-standard on reply, and the spec bound keeps the PDU within capacity by construction.
// (registers are 2 bytes each, coils pack 8 per byte).
// Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one.
const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS;
const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast<size_t>(number_of_entities) * 2;
@@ -484,9 +512,12 @@ static bool register_block_in_range(const LogString *role, uint16_t start_addres
return true;
}
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) {
// The ceiling comes from the buffer itself: push_back() drops silently, so a bound wider than the buffer
// would put a truncated frame on the wire.
template<typename Pdu> static Pdu build_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
constexpr auto max_registers = static_cast<uint16_t>((Pdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2);
Pdu pdu; // declared before every return so NRVO fires (all paths return the same object)
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), max_registers)) {
return pdu;
}
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
@@ -497,6 +528,19 @@ PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uin
return pdu;
}
static_assert((PduBuffer::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_NUM_OF_REGISTERS_TO_WRITE,
"a full-frame PDU must hold exactly MAX_NUM_OF_REGISTERS_TO_WRITE registers");
static_assert((WriteFewRegistersPdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_FEW_REGISTERS,
"the small write buffer must hold exactly MAX_FEW_REGISTERS registers");
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<PduBuffer>(start_address, values);
}
WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<WriteFewRegistersPdu>(start_address, values);
}
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address,
std::span<const uint16_t> write_values) {
+136 -17
View File
@@ -60,14 +60,11 @@ inline bool is_function_code_custom(uint8_t function_code) {
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
/// pays (recovery by timeout instead of an immediate CRC failure).
/// Exception-flagged codes (0x80 set) are always the 2-byte spec exception shape, so never unknown.
inline bool is_function_code_unknown_length(uint8_t function_code) {
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
if (is_function_code_exception(function_code))
return false;
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
@@ -87,6 +84,17 @@ inline bool is_function_code_unknown_length(uint8_t function_code) {
}
}
/// True when the underlying function code (exception bit masked off) may be broadcast (address 0).
/// Refused: the reads (including read-write), plus every other code whose response length the parser
/// knows (file record, FIFO). Allowed: the writes, and any code the parser does not know, since the
/// hub cannot tell one of those apart from a vendor write.
inline bool is_function_code_broadcastable(uint8_t function_code) {
uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK;
if (is_function_code_read(masked_function_code))
return false;
return is_function_code_write(masked_function_code) || is_function_code_unknown_length(masked_function_code);
}
// Returns the expected length of a server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
@@ -205,7 +213,7 @@ enum class SensorValueType : uint8_t {
S_DWORD = 0x4, // 2 Registers signed
BIT = 0x5,
U_DWORD_R = 0x6, // 2 Registers unsigned
S_DWORD_R = 0x7, // 2 Registers unsigned
S_DWORD_R = 0x7, // 2 Registers signed
U_QWORD = 0x8,
S_QWORD = 0x9,
U_QWORD_R = 0xA,
@@ -220,6 +228,26 @@ inline bool value_type_is_float(SensorValueType v) {
return v == SensorValueType::FP32 || v == SensorValueType::FP32_R;
}
/// Number of 16-bit registers a value of this type occupies (RAW counts as one register).
constexpr uint16_t register_width_for(SensorValueType v) {
switch (v) {
case SensorValueType::U_DWORD:
case SensorValueType::S_DWORD:
case SensorValueType::U_DWORD_R:
case SensorValueType::S_DWORD_R:
case SensorValueType::FP32:
case SensorValueType::FP32_R:
return 2;
case SensorValueType::U_QWORD:
case SensorValueType::S_QWORD:
case SensorValueType::U_QWORD_R:
case SensorValueType::S_QWORD_R:
return 4;
default:
return 1;
}
}
/// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers.
inline bool is_entity_type_binary(EntityType type) {
return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT;
@@ -260,7 +288,7 @@ inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10
* byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34
* byte_from_hex_str("1122", 0) returns 0x11
* @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* @param pos offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* the hex string is byte_pos * 2
* @return byte value
*/
@@ -272,8 +300,7 @@ inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a word from a hex string
* @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* the hex string is byte_pos * 2
* @param pos offset in bytes (see byte_from_hex_str)
* @return word value
*/
inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
@@ -282,8 +309,7 @@ inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a dword from a hex string
* @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* the hex string is byte_pos * 2
* @param pos offset in bytes (see byte_from_hex_str)
* @return dword value
*/
inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
@@ -292,8 +318,7 @@ inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
/** Get a qword from a hex string
* @param value string containing hex encoding
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
* the hex string is byte_pos * 2
* @param pos offset in bytes (see byte_from_hex_str)
* @return qword value
*/
inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
@@ -308,9 +333,9 @@ template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_
* Responses for coil are packed into bytes .
* coil 3 is bit 3 of the first response byte
* coil 9 is bit 2 of the second response byte
* @param coil number of the cil
* @param bit index of the bit to extract
* @param data modbus response buffer (uint8_t)
* @return content of coil register
* @return value of the requested bit
*/
inline bool bit_from_packed(int bit, std::span<const uint8_t> data) {
auto data_byte = bit / 8;
@@ -448,11 +473,96 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
*/
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
/// Combine two register words into a 32-bit value.
constexpr uint32_t registers_to_uint32(uint16_t high_word, uint16_t low_word) {
return (static_cast<uint32_t>(high_word) << 16) | low_word;
}
/// Combine four register words into a 64-bit value, most significant word first.
constexpr uint64_t registers_to_uint64(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
return (static_cast<uint64_t>(registers_to_uint32(word0, word1)) << 32) | registers_to_uint32(word2, word3);
}
// Always false, whatever the type: it exists only to make the static_assert below depend on the
// template argument. Not a queryable trait.
template<SensorValueType> inline constexpr bool VALUE_TYPE_SUPPORTED = false;
/** Decode one value whose type is known at compile time, from registers in host byte order.
* Unlike registers_to_number(), the type is a template argument, so only the one decode is compiled
* and the caller gets the value's natural type back rather than an int64_t. The "_R" types take the
* low word first; the rest take the high word first.
* Supports every fixed-width type: the WORD, DWORD, QWORD and FP32 families, including their _S and
* _R forms. RAW and BIT have no fixed width and fail to compile.
* Use register_width_for() for the number of registers the caller must supply.
* Note that the FP32 branches are only usable in a constant expression where std::bit_cast is
* available; elsewhere bit_cast falls back to a non-constexpr memcpy (see core/helpers.h).
*/
template<SensorValueType VALUE_TYPE> constexpr auto registers_to_value(const uint16_t *registers) {
if constexpr (VALUE_TYPE == SensorValueType::U_WORD) {
return registers[0];
} else if constexpr (VALUE_TYPE == SensorValueType::S_WORD) {
return static_cast<int16_t>(registers[0]);
} else if constexpr (VALUE_TYPE == SensorValueType::U_WORD_S) {
return byteswap(registers[0]);
} else if constexpr (VALUE_TYPE == SensorValueType::S_WORD_S) {
return static_cast<int16_t>(byteswap(registers[0]));
} else if constexpr (VALUE_TYPE == SensorValueType::U_DWORD) {
return registers_to_uint32(registers[0], registers[1]);
} else if constexpr (VALUE_TYPE == SensorValueType::U_DWORD_R) {
return registers_to_uint32(registers[1], registers[0]);
} else if constexpr (VALUE_TYPE == SensorValueType::S_DWORD) {
return static_cast<int32_t>(registers_to_uint32(registers[0], registers[1]));
} else if constexpr (VALUE_TYPE == SensorValueType::S_DWORD_R) {
return static_cast<int32_t>(registers_to_uint32(registers[1], registers[0]));
} else if constexpr (VALUE_TYPE == SensorValueType::FP32) {
return bit_cast<float>(registers_to_uint32(registers[0], registers[1]));
} else if constexpr (VALUE_TYPE == SensorValueType::FP32_R) {
return bit_cast<float>(registers_to_uint32(registers[1], registers[0]));
} else if constexpr (VALUE_TYPE == SensorValueType::U_QWORD) {
return registers_to_uint64(registers[0], registers[1], registers[2], registers[3]);
} else if constexpr (VALUE_TYPE == SensorValueType::U_QWORD_R) {
return registers_to_uint64(registers[3], registers[2], registers[1], registers[0]);
} else if constexpr (VALUE_TYPE == SensorValueType::S_QWORD) {
return static_cast<int64_t>(registers_to_uint64(registers[0], registers[1], registers[2], registers[3]));
} else if constexpr (VALUE_TYPE == SensorValueType::S_QWORD_R) {
return static_cast<int64_t>(registers_to_uint64(registers[3], registers[2], registers[1], registers[0]));
} else {
static_assert(VALUE_TYPE_SUPPORTED<VALUE_TYPE>, "registers_to_value() does not support this value type");
}
}
/// The type registers_to_value() yields for a given value type. Distinct from modbus::RegisterValues,
/// which is a container of raw words.
template<SensorValueType VALUE_TYPE>
using RegisterValueType = decltype(registers_to_value<VALUE_TYPE>(static_cast<const uint16_t *>(nullptr)));
/** The value stored at an absolute register address, or nullopt when it is not wholly inside this
* response. Lets a device decode by address rather than by offset, so a poll split across several
* requests needs no extra bookkeeping: a value outside the response simply yields nullopt.
* @param registers the response registers, in host byte order
* @param start_address the address the response begins at
* @param address the address of the wanted value
*/
template<SensorValueType VALUE_TYPE>
constexpr std::optional<RegisterValueType<VALUE_TYPE>> value_at(std::span<const uint16_t> registers,
uint16_t start_address, uint16_t address) {
if (address < start_address)
return std::nullopt;
const size_t offset = static_cast<size_t>(address) - start_address;
if (offset + register_width_for(VALUE_TYPE) > registers.size())
return std::nullopt;
return registers_to_value<VALUE_TYPE>(registers.data() + offset);
}
/// The widest standard numeric value (a QWORD) spans 4 registers, so one entity value never writes more.
static constexpr uint16_t MAX_FEW_REGISTERS = 4;
// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector,
// right-sized per shape) can be swapped in one place without touching every signature.
using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>;
using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>;
using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>;
using WriteFewRegistersPdu = StaticVector<uint8_t, WRITE_MULTIPLE_HEADER_SIZE + 2 * MAX_FEW_REGISTERS>;
/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum.
using CoilPackBuffer = StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)>;
@@ -496,6 +606,15 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
*/
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus write multiple registers command (function 0x10) on a right-sized stack buffer.
* Identical wire bytes to create_write_registers_pdu() for any accepted input.
* @param start_address modbus address of the first register to write
* @param values register values to write, at most MAX_FEW_REGISTERS (an over-long or empty set is
* rejected and an empty PDU is returned)
* @return PDU (function code + data, no address, no CRC)
*/
WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus read/write multiple registers command
* Function 0x17 Read/Write Multiple Registers
* Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17);
@@ -41,6 +41,7 @@ from .const import (
CONF_REGISTER_COUNT,
CONF_REGISTER_TYPE,
CONF_RESPONSE_SIZE,
CONF_REUSE_PREVIOUS_RANGE,
CONF_SERVER_COURTESY_RESPONSE,
CONF_SERVER_REGISTERS,
CONF_SKIP_UPDATES,
@@ -60,6 +61,13 @@ ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingCom
SensorItem = modbus_controller_ns.struct("SensorItem")
RangeReuse = modbus_controller_ns.enum("RangeReuse", is_class=True)
RANGE_REUSE = {
"auto": RangeReuse.AUTO,
True: RangeReuse.ALWAYS,
False: RangeReuse.NEVER,
}
_LOGGER = logging.getLogger(__name__)
@@ -184,13 +192,88 @@ ModbusItemBaseSchema = cv.Schema(
): cv.positive_int,
cv.Optional(CONF_BITMASK, default=0xFFFFFFFF): cv.hex_uint32_t,
cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated,
cv.Optional(CONF_FORCE_NEW_RANGE, default=False): cv.boolean,
cv.Optional(CONF_REUSE_PREVIOUS_RANGE, default="auto"): cv.Any(
cv.boolean, cv.one_of("auto", lower=True)
),
# Deprecated options, migrated by validate_range_reuse_migration(). Remove before 2027.3.0
cv.Optional(CONF_FORCE_NEW_RANGE): cv.boolean,
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.positive_int,
cv.Optional(CONF_RESPONSE_SIZE, default=0): cv.int_range(min=0, max=250),
},
)
def _derived_register_widths(config: ConfigType) -> set[int]:
"""Register widths an item derives on its own; a matching register_count is redundant."""
response_size = config.get(CONF_RESPONSE_SIZE, 0)
if (value_type := config.get(CONF_VALUE_TYPE)) is not None:
widths = {TYPE_REGISTER_MAP[value_type]}
if value_type == "RAW" and response_size > 0:
widths.add((response_size + 1) // 2)
return widths
if response_size > 0:
# text sensors: the old default was floor(response_size / 2); the derived width is now ceil
return {response_size // 2, (response_size + 1) // 2}
return {1}
def entity_label(config: ConfigType) -> str:
"""The entity's name or id, so migration messages say which entry to edit."""
label = config.get(CONF_NAME) or config.get(CONF_ID)
return str(label) if label is not None else "<unnamed>"
# Remove before 2027.3.0
def validate_range_reuse_migration(config: ConfigType) -> ConfigType:
"""Migrate the removed force_new_range/register_count options to reuse_previous_range."""
if (force_new_range := config.pop(CONF_FORCE_NEW_RANGE, None)) is not None:
if config[CONF_REUSE_PREVIOUS_RANGE] != "auto":
raise cv.Invalid(
f"'{CONF_FORCE_NEW_RANGE}' and '{CONF_REUSE_PREVIOUS_RANGE}' can't be used together; "
f"remove '{CONF_FORCE_NEW_RANGE}'"
)
if force_new_range:
_LOGGER.warning(
"%s: '%s' is deprecated; '%s: false' replaces it but only stops this entity joining "
"the PREVIOUS range - set it on the following entity too if the range must stay "
"isolated. Removed in 2027.3.0",
entity_label(config),
CONF_FORCE_NEW_RANGE,
CONF_REUSE_PREVIOUS_RANGE,
)
config[CONF_REUSE_PREVIOUS_RANGE] = False
else:
_LOGGER.warning(
"%s: '%s: false' has no effect; remove it. Removed in 2027.3.0",
entity_label(config),
CONF_FORCE_NEW_RANGE,
)
if (register_count := config.pop(CONF_REGISTER_COUNT, None)) is not None:
if (
register_count not in _derived_register_widths(config)
and register_count != 0
):
raise cv.Invalid(
f"'{CONF_REGISTER_COUNT}' has been removed; the number of registers to read is now "
f"derived from '{CONF_VALUE_TYPE}' (or '{CONF_RESPONSE_SIZE}' for RAW values and text "
f"sensors). To make one request span extra registers up to the next sensor, set "
f"'{CONF_REUSE_PREVIOUS_RANGE}: true' on the NEXT sensor instead; for RAW or text block "
f"reads set '{CONF_RESPONSE_SIZE}' to the byte count; to force multi-register writes set "
f"'use_write_multiple: true'. See "
"https://esphome.io/components/modbus_controller/"
)
_LOGGER.warning(
"%s: '%s' is now derived from '%s' (or '%s' for RAW values and text sensors) and has no "
"effect; remove it. Removed in 2027.3.0",
entity_label(config),
CONF_REGISTER_COUNT,
CONF_VALUE_TYPE,
CONF_RESPONSE_SIZE,
)
return config
def validate_modbus_register(config: ConfigType) -> ConfigType:
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
# either as "a custom frame is configured" so the address/register_type rules match.
@@ -293,20 +376,13 @@ def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType:
return config
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
def modbus_calc_properties(config: ConfigType) -> int:
byte_offset = 0
reg_count = 0
if CONF_OFFSET in config:
byte_offset = config[CONF_OFFSET]
# A CONF_BYTE_OFFSET setting overrides CONF_OFFSET
if CONF_BYTE_OFFSET in config:
byte_offset = config[CONF_BYTE_OFFSET]
if CONF_REGISTER_COUNT in config:
reg_count = config[CONF_REGISTER_COUNT]
if CONF_VALUE_TYPE in config:
value_type = config[CONF_VALUE_TYPE]
if reg_count == 0:
reg_count = TYPE_REGISTER_MAP[value_type]
if CONF_CUSTOM_PDU in config:
if CONF_ADDRESS not in config:
# generate a unique modbus address using the hash of the name
@@ -317,8 +393,7 @@ def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
value = value.encode()
config[CONF_ADDRESS] = binascii.crc_hqx(value, 0)
config[CONF_REGISTER_TYPE] = cv.enum(MODBUS_REGISTER_TYPE)("custom")
config[CONF_FORCE_NEW_RANGE] = True
return byte_offset, reg_count
return byte_offset
async def add_modbus_base_properties(
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from .. import (
RANGE_REUSE,
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
@@ -12,12 +13,13 @@ from .. import (
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
validate_range_reuse_migration,
)
from ..const import (
CONF_BITMASK,
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_TYPE,
CONF_REUSE_PREVIOUS_RANGE,
)
DEPENDENCIES = ["modbus_controller"]
@@ -38,20 +40,21 @@ CONFIG_SCHEMA = cv.All(
}
),
validate_modbus_register,
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
byte_offset, _ = modbus_calc_properties(config)
byte_offset = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_REGISTER_TYPE],
config[CONF_ADDRESS],
byte_offset,
config[CONF_BITMASK],
config[CONF_FORCE_NEW_RANGE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
)
await cg.register_component(var, config)
await binary_sensor.register_binary_sensor(var, config)
@@ -11,19 +11,22 @@ namespace esphome::modbus_controller {
class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem {
public:
ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
bool force_new_range) {
RangeReuse reuse_previous_range) {
this->register_type = register_type;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->bitmask = bitmask;
this->sensor_value_type = SensorValueType::BIT;
this->force_new_range = force_new_range;
this->reuse_previous_range = reuse_previous_range;
}
if (modbus::helpers::is_entity_type_binary(register_type)) {
this->register_count = offset + 1;
} else {
this->register_count = 1;
/// On the bit-addressed tables the bit sits at start_address + offset, so the read must span offset + 1
/// bits. Uses the offset as configured: `offset` itself is overwritten with the position in the range.
uint16_t entity_count() const override {
if (modbus::helpers::is_entity_type_binary(this->register_type)) {
return this->offset_from_start_address + 1;
}
return 1;
}
void parse_and_publish(std::span<const uint8_t> data) override;
@@ -18,6 +18,7 @@ CONF_REGISTER_LAST_ADDRESS = "register_last_address"
CONF_REGISTER_TYPE = "register_type"
CONF_REGISTER_VALUE = "register_value"
CONF_RESPONSE_SIZE = "response_size"
CONF_REUSE_PREVIOUS_RANGE = "reuse_previous_range"
CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response"
CONF_SERVER_REGISTERS = "server_registers"
CONF_SKIP_UPDATES = "skip_updates"
@@ -3,6 +3,7 @@
#include "esphome/core/log.h"
#include <cstring>
#include <limits>
namespace esphome::modbus_controller {
@@ -137,7 +138,7 @@ ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::Modbu
SensorItem *sensor)
: modbus::ModbusClientDevice(parent, address),
start_address_(sensor->start_address),
register_count_(sensor->register_count),
register_count_(sensor->entity_count()),
custom_pdu_(&sensor->custom_pdu),
controller_(&controller) {
// The PDU's first byte is its real function code; carry it so dump_config, the on_command_sent
@@ -350,129 +351,176 @@ void ModbusController::update() {
}
// walk through the sensors and determine the register ranges to read
namespace {
class RangeBuilder {
public:
explicit RangeBuilder(FixedVector<RegisterRange> &ranges) : ranges_(ranges) {}
bool can_join(const SensorItem *curr) const {
return this->have_range_ && curr->reuse_previous_range != RangeReuse::NEVER &&
this->r_.register_type == curr->register_type && curr->register_type != modbus::EntityType::CUSTOM;
}
// A sensor that joined mid-range must never anchor this - hence both address tests.
bool try_reuse_register(SensorItem *curr) {
const uint32_t range_end = this->range_end_();
if (curr->start_address != range_end - this->prev_->entity_count() ||
this->prev_->start_address + this->prev_->entity_count() != range_end ||
curr->entity_count() != this->prev_->entity_count() ||
curr->get_register_size() != this->prev_->get_register_size()) {
return false;
}
if (!place_offset(curr, static_cast<uint32_t>(this->prev_->offset) + curr->offset_from_start_address))
return false;
ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address);
return true;
}
bool try_extend(SensorItem *curr) {
const uint32_t range_end = this->range_end_();
const bool reachable =
curr->reuse_previous_range == RangeReuse::ALWAYS
? curr->start_address >= range_end
: curr->start_address == range_end && (curr->addresses_bits() || !this->range_custom_size_);
if (!reachable)
return false;
const uint16_t gap = static_cast<uint16_t>(curr->start_address - range_end);
const uint32_t new_count = this->r_.register_count + gap + curr->entity_count();
const uint16_t max_quantity =
curr->addresses_bits() ? modbus::MAX_NUM_OF_COILS_TO_READ : modbus::MAX_NUM_OF_REGISTERS_TO_READ;
const uint32_t prospective_offset =
(curr->addresses_bits() ? static_cast<uint32_t>(curr->start_address - this->r_.start_address)
: static_cast<uint32_t>(this->range_bytes_) + gap * 2) +
curr->offset_from_start_address;
if (new_count > max_quantity || !place_offset(curr, prospective_offset)) {
return false;
}
if (!curr->addresses_bits())
this->range_bytes_ += static_cast<size_t>(gap) * 2;
this->range_bytes_ += curr->get_register_size();
this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
this->r_.register_count = static_cast<uint16_t>(new_count);
ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address);
return true;
}
bool try_cover(SensorItem *curr) {
if (!this->range_shared_ || this->range_forced_ || curr->start_address < this->r_.start_address ||
curr->start_address + curr->entity_count() > this->range_end_() || this->range_custom_size_ ||
has_custom_size(curr)) {
return false;
}
const uint32_t addr_delta = curr->start_address - this->r_.start_address;
if (!place_offset(curr, (curr->addresses_bits() ? addr_delta : addr_delta * 2) + curr->offset_from_start_address))
return false;
ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, this->r_.start_address);
return true;
}
// A response dispatches to a single range per (start address, register type), so same-address items
// must share - even reuse_previous_range: false and custom entities.
bool try_share(SensorItem *curr) {
if (!this->have_range_ || this->r_.register_type != curr->register_type ||
this->r_.start_address != curr->start_address) {
return false;
}
curr->offset = curr->offset_from_start_address;
this->r_.register_count = std::max(this->r_.register_count, curr->entity_count());
this->range_bytes_ = std::max(this->range_bytes_, curr->get_register_size());
this->range_custom_size_ = this->range_custom_size_ || has_custom_size(curr);
this->range_shared_ = true;
this->range_forced_ = this->range_forced_ || curr->reuse_previous_range == RangeReuse::NEVER;
ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address);
return true;
}
bool always_declined(const SensorItem *curr) const {
return this->have_range_ && curr->reuse_previous_range == RangeReuse::ALWAYS &&
this->r_.register_type == curr->register_type && curr->start_address != this->r_.start_address;
}
void open(SensorItem *curr) {
this->close();
this->r_ = {};
this->range_bytes_ = curr->get_register_size();
this->range_custom_size_ = has_custom_size(curr);
this->range_forced_ = curr->reuse_previous_range == RangeReuse::NEVER;
this->range_shared_ = false;
curr->offset = curr->offset_from_start_address;
this->r_.start_address = curr->start_address;
this->r_.register_count = curr->entity_count();
this->r_.register_type = curr->register_type;
if (curr->register_type == modbus::EntityType::CUSTOM)
this->r_.custom_pdu = &curr->custom_pdu;
this->have_range_ = true;
}
void record(SensorItem *curr) {
curr->range_start_address = this->r_.start_address;
this->r_.sensors.insert(curr);
this->prev_ = curr;
}
void close() {
if (!this->have_range_)
return;
ESP_LOGV(TAG, "Add range 0x%X %d", this->r_.start_address, this->r_.register_count);
this->ranges_.push_back(std::move(this->r_));
this->have_range_ = false;
}
private:
uint32_t range_end_() const { return this->r_.start_address + this->r_.register_count; }
// The resolved offset must fit its uint8_t field or the sensor would parse the wrong slice.
static bool place_offset(SensorItem *curr, uint32_t offset) {
if (offset > std::numeric_limits<uint8_t>::max())
return false;
curr->offset = static_cast<uint8_t>(offset);
return true;
}
static bool has_custom_size(const SensorItem *item) {
return item->get_register_size() != static_cast<size_t>(item->entity_count()) * 2;
}
FixedVector<RegisterRange> &ranges_;
RegisterRange r_ = {};
bool have_range_ = false;
bool range_forced_ = false; // a reuse: false member blocks the coverage join
bool range_shared_ = false; // only a share-widened range absorbs by coverage
size_t range_bytes_ = 0;
bool range_custom_size_ = false;
SensorItem *prev_ = nullptr;
};
} // namespace
void ModbusController::create_polling_commands_() {
if (this->sensorset_.empty()) {
ESP_LOGW(TAG, "No sensors registered");
return;
}
// Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then
// force_new_range ahead of the rest, then address - so the walk is not purely address-ordered.
// Each keeps the address it was configured with; what is resolved here is its `offset`, the position
// of its data within the response of whichever range it ends up in.
// One range per sensor is a strict upper bound: each walk step closes at most one range, plus one
// closed after the walk. Sized to that bound so no push is ever silently dropped, then handed on by move.
// At most one range closes per sensor plus one final close, so sensorset_.size() bounds the pushes
// (FixedVector silently drops past capacity).
FixedVector<RegisterRange> ranges;
ranges.init(this->sensorset_.size());
RegisterRange r = {};
bool have_range = false;
// Set while the open range belongs to a force_new_range sensor: a range the user asked to keep
// separate must not quietly absorb other sensors.
bool range_forced = false;
// Set once a sensor has joined by sharing the range's start address, which widens the read. Only a
// widened range can absorb a later sensor by coverage: ranges that were kept apart before stay apart,
// so their frames and polling rates are untouched.
bool range_shared = false;
// Bytes the range's registers have consumed so far. An extending sensor starts after them, so a
// register that returns more bytes than its count implies pushes the sensors after it along.
// range_custom_size records whether any of them returns something other than two bytes per register,
// which is what makes a position inside the range impossible to work out from addresses alone. Coils
// count as such: they carry one bit per address, so bit ranges never take the coverage join.
size_t range_bytes = 0;
bool range_custom_size = false;
SensorItem *prev = nullptr;
RangeBuilder builder(ranges);
for (SensorItem *curr : this->sensorset_) {
ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u addr=%p", curr->start_address, curr->register_count,
ESP_LOGV(TAG, "Register: 0x%X width=%u size=%zu offset=%u addr=%p", curr->start_address, curr->entity_count(),
curr->get_register_size(), curr->offset, curr);
const bool custom_size = curr->get_register_size() != static_cast<size_t>(curr->register_count) * 2;
bool join = false;
if (have_range && !curr->force_new_range && r.register_type == curr->register_type &&
curr->register_type != modbus::EntityType::CUSTOM) {
if (curr->start_address == (r.start_address + r.register_count - prev->register_count) &&
prev->start_address + prev->register_count == r.start_address + r.register_count &&
curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) {
// A second sensor on the register(s) the previous one covers: it reads those same bytes,
// starting where that sensor's offset pointed, so a chain configured 0/2/4 resolves to 0/2/6.
// Both address tests matter. The first identifies the previous sensor's register by working back
// from the range's end, which only describes it while it actually sits there - hence the second.
// A sensor that joined mid-range must never anchor this, or the next one inherits its offset.
curr->offset = static_cast<uint8_t>(prev->offset + curr->offset_from_start_address);
join = true;
ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address);
} else if (curr->start_address == (r.start_address + r.register_count)) {
// The next contiguous register(s): the data begins after what the range has consumed so far -
// the byte cursor for registers, the distance in bits for coils.
curr->offset =
static_cast<uint8_t>((curr->addresses_bits() ? curr->start_address - r.start_address : range_bytes) +
curr->offset_from_start_address);
range_bytes += curr->get_register_size();
range_custom_size = range_custom_size || custom_size;
r.register_count += curr->register_count;
join = true;
ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address);
} else if (range_shared && !range_forced && curr->start_address >= r.start_address &&
curr->start_address + curr->register_count <= r.start_address + r.register_count &&
!range_custom_size && !custom_size) {
// The registers already fall inside a range that a shared-address join widened, so this sensor
// reads its slice of that response instead of adding an overlapping second poll. The guards keep
// it narrow: only a widened range, never a force-isolated one; only where every register in the
// range returns two bytes, so interior positions follow from the addresses; only sensors genuinely
// inside it, which is why the lower bound is needed given the walk is not address-ordered.
const uint16_t addr_delta = curr->start_address - r.start_address;
curr->offset = static_cast<uint8_t>((curr->addresses_bits() ? addr_delta : addr_delta * 2) +
curr->offset_from_start_address);
join = true;
ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, r.start_address);
}
bool join = builder.can_join(curr) &&
(builder.try_reuse_register(curr) || builder.try_extend(curr) || builder.try_cover(curr));
if (!join && builder.always_declined(curr)) {
ESP_LOGW(TAG, "reuse_previous_range on 0x%X cannot join the previous range; starting a new range",
curr->start_address);
}
// Sensors on the same start address have to share one range: a response is dispatched to a single
// range per (start_address, register_type), so a second range with that key would never receive
// data. This holds for force_new_range and custom entities too. The read widens to cover whichever
// sensor needs the most registers, which also fixes a short read for coils that use offset.
if (!join && have_range && r.register_type == curr->register_type && r.start_address == curr->start_address) {
curr->offset = curr->offset_from_start_address; // shares the range start
r.register_count = std::max(r.register_count, curr->register_count);
range_bytes = std::max(range_bytes, curr->get_register_size());
range_custom_size = range_custom_size || custom_size;
range_shared = true;
range_forced = range_forced || curr->force_new_range;
join = true;
ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address);
}
if (!join) {
if (have_range) {
ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count);
ranges.push_back(std::move(r));
}
r = {};
range_bytes = curr->get_register_size();
range_custom_size = custom_size;
range_forced = curr->force_new_range;
range_shared = false;
curr->offset = curr->offset_from_start_address;
r.start_address = curr->start_address;
r.register_count = curr->register_count;
r.register_type = curr->register_type;
if (curr->register_type == modbus::EntityType::CUSTOM)
r.custom_pdu = &curr->custom_pdu;
have_range = true;
}
// Every member records its range's first register. The resolved offset is relative to it, so the
// two together give the sensor's real position, and the address a write entity targets.
curr->range_start_address = r.start_address;
r.sensors.insert(curr);
prev = curr;
join = join || builder.try_share(curr);
if (!join)
builder.open(curr);
builder.record(curr);
}
if (have_range) {
ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count);
ranges.push_back(std::move(r));
}
// Staged in a setup-time vector so the device storage can be sized exactly (see polling_devices_).
builder.close();
this->polling_devices_.init(ranges.size());
for (auto &range : ranges) {
this->polling_devices_.emplace_back(*this, std::move(range));
@@ -490,8 +538,8 @@ void ModbusController::dump_config() {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGCONFIG(TAG, "sensormap");
for (auto &it : this->sensorset_) {
ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X count=%d size=%zu",
static_cast<uint8_t>(it->register_type), it->start_address, it->offset, it->register_count,
ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X width=%u size=%zu",
static_cast<uint8_t>(it->register_type), it->start_address, it->offset, it->entity_count(),
it->get_register_size());
}
ESP_LOGCONFIG(TAG, "ranges");
@@ -126,6 +126,16 @@ inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value
class ModbusController;
/// How an item relates to the register range built just before it (same register type, address order).
/// The numeric order doubles as the comparator tiebreak for items at the same address (see
/// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and
/// shares a range it did not start (items on one address must share, see create_polling_commands_()).
enum class RangeReuse : uint8_t {
AUTO = 0, // join when adjacent and the position in the reply is exact (no non-standard response_size ahead)
ALWAYS = 1, // join unconditionally, reading across any address gap
NEVER = 2, // never join backward (later items may still extend this item's range)
};
class SensorItem {
public:
/// Parse this sensor's slice out of its range's response and publish it. The span points into the
@@ -159,11 +169,26 @@ class SensorItem {
}
void set_custom_pdu(std::initializer_list<uint8_t> pdu) { this->custom_pdu.set(pdu.begin(), pdu.size()); }
/// Entities this item spans: one bit for bit-addressed types, ceil(bytes / 2) registers for RAW
/// with a response_size, else the value type's register width.
virtual uint16_t entity_count() const {
if (modbus::helpers::is_entity_type_binary(this->register_type)) {
return 1;
}
if (this->sensor_value_type == SensorValueType::RAW && this->response_bytes > 0) {
return (this->response_bytes + 1) / 2;
}
return modbus::helpers::register_width_for(this->sensor_value_type);
}
/// Bytes this item's registers occupy in a response: one per bit for bit-addressed types; response_size
/// when set (devices that answer more bytes per register than the standard two); else two per register.
size_t virtual get_register_size() const {
if (this->addresses_bits()) {
return 1;
} else { // if CONF_RESPONSE_BYTES is used override the default
return response_bytes > 0 ? response_bytes : register_count * 2;
return response_bytes > 0 ? response_bytes : this->entity_count() * 2;
}
}
// Override register size for modbus devices not using 1 register for one dword
@@ -177,7 +202,6 @@ class SensorItem {
/// for the registers ahead of it (including wide response_size ones) and for any offset inherited
/// from an earlier sensor sharing the same register.
uint8_t offset{0};
uint8_t register_count{0};
uint8_t response_bytes{0};
/// The offset exactly as configured: measured from this sensor's own start_address, where `offset`
/// is measured from the first register of the range it ends up polled in. Same units as `offset` -
@@ -188,7 +212,7 @@ class SensorItem {
/// First register of the range this sensor is polled in; equals start_address for an unpolled item.
uint16_t range_start_address{0};
SmallInlineBuffer<8> custom_pdu{};
bool force_new_range{false};
RangeReuse reuse_previous_range{RangeReuse::AUTO};
};
// ModbusController::create_polling_commands_ tries to optimize register range
@@ -201,16 +225,17 @@ class SensorItemsComparator {
return lhs->register_type < rhs->register_type;
}
// ensure that sensor with force_new_range set are before the others
if (lhs->force_new_range != rhs->force_new_range) {
return lhs->force_new_range > rhs->force_new_range;
}
// sort by start address
if (lhs->start_address != rhs->start_address) {
return lhs->start_address < rhs->start_address;
}
// at the same address: AUTO before ALWAYS before NEVER, so a NEVER item never starts the range
// the others at that address are then forced to share (see RangeReuse)
if (lhs->reuse_previous_range != rhs->reuse_previous_range) {
return lhs->reuse_previous_range < rhs->reuse_previous_range;
}
// sort by the offset as configured (ensures update of sensors in ascending order). The resolved
// `offset` is deliberately not used: ranges are built while iterating this set and assign it, and
// a sort key that changed under the iteration would corrupt the set's ordering.
@@ -229,8 +254,8 @@ using SensorSet = std::set<SensorItem *, SensorItemsComparator>;
struct RegisterRange {
uint16_t start_address;
modbus::EntityType register_type;
uint8_t register_count;
SensorSet sensors; // all sensors of this range
uint16_t register_count; // registers (or bits) the poll command reads; joins across gaps can exceed 255
SensorSet sensors; // all sensors of this range
/// A custom range polls this PDU, referenced from the sensor that opened the range.
const SmallInlineBuffer<8> *custom_pdu{nullptr};
};
@@ -17,20 +17,22 @@ from esphome.const import (
from esphome.types import ConfigType
from .. import (
RANGE_REUSE,
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
modbus_calc_properties,
modbus_controller_ns,
validate_custom_pdu_item,
validate_range_reuse_migration,
)
from ..const import (
CONF_BITMASK,
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_TYPE,
CONF_REUSE_PREVIOUS_RANGE,
CONF_USE_WRITE_MULTIPLE,
CONF_VALUE_TYPE,
CONF_WRITE_LAMBDA,
@@ -86,13 +88,14 @@ CONFIG_SCHEMA = cv.All(
),
validate_min_max,
validate_modbus_number,
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
byte_offset = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_REGISTER_TYPE],
@@ -100,8 +103,7 @@ async def to_code(config: ConfigType) -> None:
byte_offset,
config[CONF_BITMASK],
config[CONF_VALUE_TYPE],
reg_count,
config[CONF_FORCE_NEW_RANGE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
)
await cg.register_component(var, config)
@@ -83,10 +83,10 @@ void ModbusNumber::control(float value) {
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
this->get_name().c_str(), this->start_address, this->entity_count(), value, write_value);
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
if (this->entity_count() == 1 && !this->use_write_multiple_) {
queued = this->write_single_register(this->write_address(), data[0]);
} else {
queued = this->write_multiple_registers(this->write_address(), data);
@@ -13,14 +13,13 @@ using value_to_data_t = std::function<float>(float);
class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity {
public:
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
SensorValueType value_type, int register_count, bool force_new_range) {
SensorValueType value_type, RangeReuse reuse_previous_range) {
this->register_type = register_type;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->bitmask = bitmask;
this->sensor_value_type = value_type;
this->register_count = register_count;
this->force_new_range = force_new_range;
this->reuse_previous_range = reuse_previous_range;
};
void dump_config() override;
@@ -1,3 +1,5 @@
import logging
import esphome.codegen as cg
from esphome.components import output
from esphome.components.modbus.helpers import (
@@ -12,6 +14,7 @@ from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
SensorItem,
entity_label,
modbus_calc_properties,
modbus_controller_ns,
reject_odd_holding_write_offset,
@@ -19,13 +22,18 @@ from .. import (
from ..const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_COUNT,
CONF_REGISTER_TYPE,
CONF_REUSE_PREVIOUS_RANGE,
CONF_USE_WRITE_MULTIPLE,
CONF_VALUE_TYPE,
CONF_WRITE_LAMBDA,
)
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["modbus_controller"]
CODEOWNERS = ["@martgras"]
@@ -38,26 +46,30 @@ ModbusBinaryOutput = modbus_controller_ns.class_(
)
CONFIG_SCHEMA = cv.typed_schema(
{
"coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
{
cv.GenerateID(): cv.declare_id(ModbusBinaryOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
"custom_pdu is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
"custom_command is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
),
"holding": cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
def _warn_unused_range_options(config: ConfigType) -> ConfigType:
# Outputs are write-only and never polled, so nothing here builds a range for them. The write
# spans whatever the payload holds, so register_count no longer bounds it either.
for key in (CONF_FORCE_NEW_RANGE, CONF_REGISTER_COUNT):
if config.pop(key, None) is not None:
_LOGGER.warning(
"%s: '%s' has no effect on outputs; remove it. Removed in 2027.3.0",
entity_label(config),
key,
)
if config.pop(CONF_REUSE_PREVIOUS_RANGE, None) not in (None, "auto"):
raise cv.Invalid(
f"'{CONF_REUSE_PREVIOUS_RANGE}' has no effect on outputs: they are write-only and are "
f"never part of a polled range. Remove it."
)
return config
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
"coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
{
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
cv.GenerateID(): cv.declare_id(ModbusBinaryOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
"custom_pdu is not supported for outputs; use a write_lambda instead"
@@ -65,25 +77,42 @@ CONFIG_SCHEMA = cv.typed_schema(
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
"custom_command is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
SENSOR_VALUE_TYPE
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
),
reject_odd_holding_write_offset,
),
},
lower=True,
key=CONF_REGISTER_TYPE,
default_type="holding",
"holding": cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
{
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
"custom_pdu is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
"custom_command is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
SENSOR_VALUE_TYPE
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
),
reject_odd_holding_write_offset,
),
},
lower=True,
key=CONF_REGISTER_TYPE,
default_type="holding",
),
_warn_unused_range_options,
)
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
byte_offset = modbus_calc_properties(config)
# Binary Output
write_template = None
if config[CONF_REGISTER_TYPE] == "coil":
@@ -109,7 +138,6 @@ async def to_code(config: ConfigType) -> None:
config[CONF_ADDRESS],
byte_offset,
config[CONF_VALUE_TYPE],
reg_count,
)
cg.add(var.set_write_multiply(config[CONF_MULTIPLY]))
if CONF_WRITE_LAMBDA in config:
@@ -46,29 +46,24 @@ void ModbusFloatOutput::write_state(float value) {
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
}
ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->start_address, this->register_count, value, original_value);
ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%u new value=%.02f (val=%.02f)",
this->start_address, this->entity_count(), value, original_value);
// The command declares register_count registers, so the payload must be exactly that many words;
// anything else would put a byte count on the wire that disagrees with the quantity field.
// number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0].
// float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0].
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating output");
return;
}
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
// A payload wider than the declared range means the config and the lambda disagree - drop it.
if (data.size() > this->register_count) {
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
this->register_count);
// The value type sets the write width, so a wider payload means the config and the lambda disagree.
if (data.size() > this->entity_count()) {
ESP_LOGE(TAG, "Payload has %zu registers but the value type only spans %u; dropping write", data.size(),
this->entity_count());
return;
}
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
if (this->entity_count() == 1 && !this->use_write_multiple_) {
queued = this->write_single_register(this->write_address(), data[0]);
} else {
queued = this->write_multiple_registers(this->write_address(), data);
@@ -85,7 +80,7 @@ void ModbusFloatOutput::dump_config() {
" Device start address: 0x%X\n"
" Register count: %d\n"
" Value type: %d",
this->start_address, this->register_count, static_cast<int>(this->sensor_value_type));
this->start_address, this->entity_count(), static_cast<int>(this->sensor_value_type));
}
// ModbusBinaryOutput
@@ -145,7 +140,7 @@ void ModbusBinaryOutput::dump_config() {
" Device start address: 0x%X\n"
" Register count: %d\n"
" Value type: %d",
this->start_address, this->register_count, static_cast<int>(this->sensor_value_type));
this->start_address, this->entity_count(), static_cast<int>(this->sensor_value_type));
}
} // namespace esphome::modbus_controller
@@ -10,13 +10,12 @@ namespace esphome::modbus_controller {
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity {
public:
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type) {
this->register_type = modbus::EntityType::HOLDING;
// A byte offset folds into the address as whole registers; odd offsets are rejected at validation.
this->set_address(start_address + offset / 2);
this->set_offset_from_start_address(0);
this->bitmask = 0xFFFFFFFF;
this->register_count = register_count;
this->sensor_value_type = value_type;
}
void dump_config() override;
@@ -46,7 +45,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
this->set_address(start_address + offset);
this->bitmask = 0xFFFFFFFF;
this->sensor_value_type = SensorValueType::BIT;
this->register_count = 1;
this->set_offset_from_start_address(0);
}
void dump_config() override;
@@ -3,25 +3,24 @@ from typing import Any
import esphome.codegen as cg
from esphome.components import select
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
TYPE_REGISTER_MAP,
RegisterValues,
)
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
from esphome.types import ConfigType
from .. import (
RANGE_REUSE,
ModbusController,
SensorItem,
modbus_controller_ns,
validate_range_reuse_migration,
validate_skip_updates_deprecated,
)
from ..const import (
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_COUNT,
CONF_REUSE_PREVIOUS_RANGE,
CONF_SKIP_UPDATES,
CONF_USE_WRITE_MULTIPLE,
CONF_VALUE_TYPE,
@@ -55,18 +54,6 @@ def ensure_option_map() -> Callable[[Any], dict[str, int]]:
return validator
def register_count_value_type_min(value: ConfigType) -> ConfigType:
reg_count = value.get(CONF_REGISTER_COUNT)
if reg_count is not None:
value_type = value[CONF_VALUE_TYPE]
min_register_count = TYPE_REGISTER_MAP[value_type]
if min_register_count > reg_count:
raise cv.Invalid(
f"Value type {value_type} needs at least {min_register_count} registers"
)
return value
INTEGER_SENSOR_VALUE_TYPE = {
key: value for key, value in SENSOR_VALUE_TYPE.items() if not key.startswith("FP")
}
@@ -81,9 +68,13 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
INTEGER_SENSOR_VALUE_TYPE
),
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
cv.Optional(CONF_SKIP_UPDATES): validate_skip_updates_deprecated,
cv.Optional(CONF_FORCE_NEW_RANGE, default=False): cv.boolean,
cv.Optional(CONF_REUSE_PREVIOUS_RANGE, default="auto"): cv.Any(
cv.boolean, cv.one_of("auto", lower=True)
),
# Deprecated options, migrated by validate_range_reuse_migration(). Remove before 2027.3.0
cv.Optional(CONF_FORCE_NEW_RANGE): cv.boolean,
cv.Optional(CONF_REGISTER_COUNT): cv.positive_int,
cv.Required(CONF_OPTIONSMAP): ensure_option_map(),
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
@@ -91,24 +82,18 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
},
),
register_count_value_type_min,
validate_range_reuse_migration,
)
async def to_code(config: ConfigType) -> None:
value_type = config[CONF_VALUE_TYPE]
reg_count = config.get(CONF_REGISTER_COUNT)
if reg_count is None:
reg_count = TYPE_REGISTER_MAP[value_type]
options_map = config[CONF_OPTIONSMAP]
var = cg.new_Pvariable(
config[CONF_ID],
value_type,
config[CONF_VALUE_TYPE],
config[CONF_ADDRESS],
reg_count,
config[CONF_FORCE_NEW_RANGE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
list(options_map.values()),
)
@@ -83,19 +83,17 @@ void ModbusSelect::control(size_t index) {
}
}
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
// A payload wider than the declared range means the config and the lambda disagree - drop it.
if (data.size() > this->register_count) {
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
this->register_count);
// A write covers exactly the registers the value occupies: the quantity comes from the payload. A
// payload wider than the value type's register width means the config and the lambda disagree - drop it.
if (data.size() > this->entity_count()) {
ESP_LOGE(TAG, "Payload has %zu registers but the value type only spans %u; dropping write", data.size(),
this->entity_count());
return;
}
const uint16_t write_address = this->write_address();
bool queued;
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
if ((this->entity_count() == 1) && (!this->use_write_multiple_)) {
queued = this->write_single_register(write_address, data[0]);
} else {
queued = this->write_multiple_registers(write_address, data);
@@ -11,16 +11,15 @@ namespace esphome::modbus_controller {
class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity {
public:
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, RangeReuse reuse_previous_range,
std::vector<int64_t> mapping) {
this->register_type = modbus::EntityType::HOLDING; // not configurable
this->sensor_value_type = sensor_value_type;
this->set_address(start_address);
this->set_offset_from_start_address(0); // not configurable
this->bitmask = 0xFFFFFFFF; // not configurable
this->register_count = register_count;
this->response_bytes = 0; // not configurable
this->force_new_range = force_new_range;
this->response_bytes = 0; // not configurable
this->reuse_previous_range = reuse_previous_range;
this->mapping_ = std::move(mapping);
}
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from .. import (
RANGE_REUSE,
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
@@ -12,13 +13,13 @@ from .. import (
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
validate_range_reuse_migration,
)
from ..const import (
CONF_BITMASK,
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_COUNT,
CONF_REGISTER_TYPE,
CONF_REUSE_PREVIOUS_RANGE,
CONF_VALUE_TYPE,
)
@@ -38,27 +39,25 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE),
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE),
cv.Optional(CONF_REGISTER_COUNT, default=0): cv.positive_int,
}
),
validate_modbus_register,
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
byte_offset, reg_count = modbus_calc_properties(config)
value_type = config[CONF_VALUE_TYPE]
byte_offset = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_REGISTER_TYPE],
config[CONF_ADDRESS],
byte_offset,
config[CONF_BITMASK],
value_type,
reg_count,
config[CONF_FORCE_NEW_RANGE],
config[CONF_VALUE_TYPE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
)
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
@@ -11,14 +11,13 @@ namespace esphome::modbus_controller {
class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem {
public:
ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
SensorValueType value_type, int register_count, bool force_new_range) {
SensorValueType value_type, RangeReuse reuse_previous_range) {
this->register_type = register_type;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->bitmask = bitmask;
this->sensor_value_type = value_type;
this->register_count = register_count;
this->force_new_range = force_new_range;
this->reuse_previous_range = reuse_previous_range;
}
void parse_and_publish(std::span<const uint8_t> data) override;
@@ -6,6 +6,7 @@ from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
from esphome.types import ConfigType
from .. import (
RANGE_REUSE,
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
@@ -14,12 +15,13 @@ from .. import (
reject_odd_holding_write_offset,
validate_custom_pdu_item,
validate_modbus_register,
validate_range_reuse_migration,
)
from ..const import (
CONF_BITMASK,
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_REGISTER_TYPE,
CONF_REUSE_PREVIOUS_RANGE,
CONF_USE_WRITE_MULTIPLE,
CONF_WRITE_LAMBDA,
)
@@ -54,20 +56,21 @@ CONFIG_SCHEMA = cv.All(
),
validate_modbus_register,
_validate_holding_offset,
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config: ConfigType) -> None:
byte_offset, _ = modbus_calc_properties(config)
byte_offset = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_REGISTER_TYPE],
config[CONF_ADDRESS],
byte_offset,
config[CONF_BITMASK],
config[CONF_FORCE_NEW_RANGE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
)
await cg.register_component(var, config)
await switch.register_switch(var, config)
@@ -11,13 +11,12 @@ namespace esphome::modbus_controller {
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity {
public:
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
bool force_new_range) {
RangeReuse reuse_previous_range) {
this->register_type = register_type;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->bitmask = bitmask;
this->sensor_value_type = SensorValueType::BIT;
this->register_count = 1;
// A holding byte offset folds into the address as whole registers (odd offsets are rejected at
// validation: a 16-bit register write cannot target half a register); a coil offset is a coil count.
if (register_type == modbus::EntityType::HOLDING) {
@@ -27,7 +26,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
this->set_address(start_address + offset);
this->set_offset_from_start_address(0);
}
this->force_new_range = force_new_range;
this->reuse_previous_range = reuse_previous_range;
};
void setup() override;
void write_state(bool state) override;
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from .. import (
RANGE_REUSE,
ModbusItemBaseSchema,
SensorItem,
add_modbus_base_properties,
@@ -12,14 +13,14 @@ from .. import (
modbus_controller_ns,
validate_custom_pdu_item,
validate_modbus_register,
validate_range_reuse_migration,
)
from ..const import (
CONF_FORCE_NEW_RANGE,
CONF_MODBUS_CONTROLLER_ID,
CONF_RAW_ENCODE,
CONF_REGISTER_COUNT,
CONF_REGISTER_TYPE,
CONF_RESPONSE_SIZE,
CONF_REUSE_PREVIOUS_RANGE,
)
DEPENDENCIES = ["modbus_controller"]
@@ -47,32 +48,27 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(ModbusTextSensor),
cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE),
cv.Optional(CONF_REGISTER_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_RESPONSE_SIZE, default=2): cv.positive_int,
cv.Optional(CONF_RESPONSE_SIZE, default=2): cv.int_range(min=1, max=250),
cv.Optional(CONF_RAW_ENCODE, default="ANSI"): cv.enum(RAW_ENCODING),
}
),
validate_modbus_register,
validate_range_reuse_migration,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
byte_offset, reg_count = modbus_calc_properties(config)
response_size = config[CONF_RESPONSE_SIZE]
reg_count = config[CONF_REGISTER_COUNT]
if reg_count == 0:
reg_count = response_size // 2
byte_offset = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_REGISTER_TYPE],
config[CONF_ADDRESS],
byte_offset,
reg_count,
config[CONF_RESPONSE_SIZE],
config[CONF_RAW_ENCODE],
config[CONF_FORCE_NEW_RANGE],
RANGE_REUSE[config[CONF_REUSE_PREVIOUS_RANGE]],
)
await cg.register_component(var, config)
@@ -12,17 +12,16 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 };
class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem {
public:
ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count,
uint16_t response_bytes, RawEncoding encode, bool force_new_range) {
ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint16_t response_bytes,
RawEncoding encode, RangeReuse reuse_previous_range) {
this->register_type = register_type;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
this->response_bytes = response_bytes;
this->register_count = register_count;
this->encode_ = encode;
this->bitmask = 0xFFFFFFFF;
this->sensor_value_type = SensorValueType::RAW;
this->force_new_range = force_new_range;
this->reuse_previous_range = reuse_previous_range;
}
void dump_config() override;
+51 -49
View File
@@ -3,62 +3,64 @@
namespace esphome::pzemac {
namespace helpers = modbus::helpers;
static const char *const TAG = "pzemac";
static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42;
static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers
void PZEMAC::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < 20) {
ESP_LOGW(TAG, "Invalid size for PZEM AC!");
// Register map, see https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809
// 32-bit values are two registers, low word first.
static const uint16_t PZEM_REGISTER_VOLTAGE = 0; // 1 register, 0.1 V
static const uint16_t PZEM_REGISTER_CURRENT = 1; // 2 registers, 0.001 A
static const uint16_t PZEM_REGISTER_ACTIVE_POWER = 3; // 2 registers, 0.1 W
static const uint16_t PZEM_REGISTER_ACTIVE_ENERGY = 5; // 2 registers, 1 Wh
static const uint16_t PZEM_REGISTER_FREQUENCY = 7; // 1 register, 0.1 Hz
static const uint16_t PZEM_REGISTER_POWER_FACTOR = 8; // 1 register, 0.01
void PZEMAC::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
// Publish a sensor if its register(s) are in this response; skipping absent registers keeps this
// correct for any read range, so the poll may be split into multiple requests.
auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_WORD>(registers, start_address, reg))
sensor->publish_state(*value / divisor);
};
auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_DWORD_R>(registers, start_address, reg))
sensor->publish_state(*value / divisor);
};
publish_1_register(this->voltage_sensor_, PZEM_REGISTER_VOLTAGE, 10.0f);
publish_2_registers(this->current_sensor_, PZEM_REGISTER_CURRENT, 1000.0f);
publish_2_registers(this->power_sensor_, PZEM_REGISTER_ACTIVE_POWER, 10.0f);
publish_2_registers(this->energy_sensor_, PZEM_REGISTER_ACTIVE_ENERGY, 1.0f);
publish_1_register(this->frequency_sensor_, PZEM_REGISTER_FREQUENCY, 10.0f);
publish_1_register(this->power_factor_sensor_, PZEM_REGISTER_POWER_FACTOR, 100.0f);
}
void PZEMAC::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
modbus::ResponseStatus status) {
// The only custom request this component sends is the energy reset; acknowledge its echo here so
// the default unhandled-response warning stays meaningful.
if (!request_pdu.empty() && request_pdu[0] == PZEM_CMD_RESET_ENERGY) {
if (modbus::succeeded(status)) {
ESP_LOGD(TAG, "Energy reset acknowledged");
} else {
ESP_LOGW(TAG, "Energy reset rejected");
}
return;
}
// See https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809
// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// 01 04 14 08 D1 00 6C 00 00 00 F4 00 00 00 26 00 00 01 F4 00 64 00 00 51 34
// Id Cc Sz Volt- Current---- Power------ Energy----- Frequ PFact Alarm Crc--
// 0 2 6 10 14 16
auto pzem_get_16bit = [&](size_t i) -> uint16_t {
return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0);
};
auto pzem_get_32bit = [&](size_t i) -> uint32_t {
return (uint32_t(pzem_get_16bit(i + 2)) << 16) | (uint32_t(pzem_get_16bit(i + 0)) << 0);
};
uint16_t raw_voltage = pzem_get_16bit(0);
float voltage = raw_voltage / 10.0f; // max 6553.5 V
uint32_t raw_current = pzem_get_32bit(2);
float current = raw_current / 1000.0f; // max 4294967.295 A
uint32_t raw_active_power = pzem_get_32bit(6);
float active_power = raw_active_power / 10.0f; // max 429496729.5 W
float active_energy = static_cast<float>(pzem_get_32bit(10));
uint16_t raw_frequency = pzem_get_16bit(14);
float frequency = raw_frequency / 10.0f;
uint16_t raw_power_factor = pzem_get_16bit(16);
float power_factor = raw_power_factor / 100.0f;
ESP_LOGD(TAG, "PZEM AC: V=%.1f V, I=%.3f A, P=%.1f W, E=%.1f Wh, F=%.1f Hz, PF=%.2f", voltage, current, active_power,
active_energy, frequency, power_factor);
if (this->voltage_sensor_ != nullptr)
this->voltage_sensor_->publish_state(voltage);
if (this->current_sensor_ != nullptr)
this->current_sensor_->publish_state(current);
if (this->power_sensor_ != nullptr)
this->power_sensor_->publish_state(active_power);
if (this->energy_sensor_ != nullptr)
this->energy_sensor_->publish_state(active_energy);
if (this->frequency_sensor_ != nullptr)
this->frequency_sensor_->publish_state(frequency);
if (this->power_factor_sensor_ != nullptr)
this->power_factor_sensor_->publish_state(power_factor);
modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status);
}
void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); }
+4 -1
View File
@@ -22,7 +22,10 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
modbus::ResponseStatus status) override;
void dump_config() override;
+46 -38
View File
@@ -3,55 +3,63 @@
namespace esphome::pzemdc {
namespace helpers = modbus::helpers;
static const char *const TAG = "pzemdc";
static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42;
static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers
static const uint8_t PZEM_REGISTER_COUNT = 8; // 8x 16-bit registers
void PZEMDC::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < 16) {
ESP_LOGW(TAG, "Invalid size for PZEM DC!");
return;
}
// Register map, see https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809
// 32-bit values are two registers, low word first.
static const uint16_t PZEM_REGISTER_VOLTAGE = 0; // 1 register, 0.01 V
static const uint16_t PZEM_REGISTER_CURRENT = 1; // 1 register, 0.01 A
static const uint16_t PZEM_REGISTER_POWER = 2; // 2 registers, 0.1 W
static const uint16_t PZEM_REGISTER_ENERGY = 4; // 2 registers, 1 Wh
// See https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809
// 0 1 2 3 4 5 6 7 = ModBus register
// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 = Buffer index
// 01 04 10 05 40 00 0A 00 0D 00 00 00 02 00 00 00 00 00 00 D6 29
// Id Cc Sz Volt- Curre Power------ Energy----- HiAlm LoAlm Crc--
void PZEMDC::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
auto pzem_get_16bit = [&](size_t i) -> uint16_t {
return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0);
};
auto pzem_get_32bit = [&](size_t i) -> uint32_t {
return (uint32_t(pzem_get_16bit(i + 2)) << 16) | (uint32_t(pzem_get_16bit(i + 0)) << 0);
// Publish a sensor if its register(s) are in this response; skipping absent registers keeps this
// correct for any read range, so the poll may be split into multiple requests.
auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_WORD>(registers, start_address, reg))
sensor->publish_state(*value / divisor);
};
uint16_t raw_voltage = pzem_get_16bit(0);
float voltage = raw_voltage / 100.0f; // max 655.35 V
auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::U_DWORD_R>(registers, start_address, reg))
sensor->publish_state(*value / divisor);
};
uint16_t raw_current = pzem_get_16bit(2);
float current = raw_current / 100.0f; // max 655.35 A
uint32_t raw_power = pzem_get_32bit(4);
float power = raw_power / 10.0f; // max 429496729.5 W
uint32_t raw_energy = pzem_get_32bit(8);
float energy = raw_energy / 1000.0f; // max 4294967.295 kWh
ESP_LOGD(TAG, "PZEM DC: V=%.1f V, I=%.3f A, P=%.1f W", voltage, current, power);
if (this->voltage_sensor_ != nullptr)
this->voltage_sensor_->publish_state(voltage);
if (this->current_sensor_ != nullptr)
this->current_sensor_->publish_state(current);
if (this->power_sensor_ != nullptr)
this->power_sensor_->publish_state(power);
if (this->energy_sensor_ != nullptr)
this->energy_sensor_->publish_state(energy);
publish_1_register(this->voltage_sensor_, PZEM_REGISTER_VOLTAGE, 100.0f);
publish_1_register(this->current_sensor_, PZEM_REGISTER_CURRENT, 100.0f);
publish_2_registers(this->power_sensor_, PZEM_REGISTER_POWER, 10.0f);
publish_2_registers(this->energy_sensor_, PZEM_REGISTER_ENERGY, 1000.0f);
}
void PZEMDC::update() { this->read_input_registers(0, 8); }
void PZEMDC::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
modbus::ResponseStatus status) {
// The only custom request this component sends is the energy reset; acknowledge its echo here so
// the default unhandled-response warning stays meaningful.
if (!request_pdu.empty() && request_pdu[0] == PZEM_CMD_RESET_ENERGY) {
if (modbus::succeeded(status)) {
ESP_LOGD(TAG, "Energy reset acknowledged");
} else {
ESP_LOGW(TAG, "Energy reset rejected");
}
return;
}
modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status);
}
void PZEMDC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); }
void PZEMDC::dump_config() {
ESP_LOGCONFIG(TAG,
"PZEMDC:\n"
+4 -1
View File
@@ -18,7 +18,10 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
modbus::ResponseStatus status) override;
void dump_config() override;
+28 -65
View File
@@ -1,85 +1,48 @@
#include "sdm_meter.h"
#include "sdm_meter_registers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::sdm_meter {
namespace helpers = modbus::helpers;
static const char *const TAG = "sdm_meter";
static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers
static const uint8_t MODBUS_REGISTER_COUNT = 80; // 80 x 16-bit registers (40 float values)
void SDMMeter::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < MODBUS_REGISTER_COUNT * 2) {
ESP_LOGW(TAG, "Invalid size for SDMMeter!");
return;
}
void SDMMeter::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
auto sdm_meter_get_float = [&](size_t i) -> float {
uint32_t temp = encode_uint32(data[i], data[i + 1], data[i + 2], data[i + 3]);
float f;
memcpy(&f, &temp, sizeof(f));
return f;
// Publish a sensor if both of its registers are in this response; skipping absent registers keeps
// this correct for any read range, so the poll may be split into multiple requests.
auto publish = [&](uint16_t reg, sensor::Sensor *sensor) {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::FP32>(registers, start_address, reg))
sensor->publish_state(*value);
};
for (uint8_t i = 0; i < 3; i++) {
auto phase = this->phases_[i];
auto &phase = this->phases_[i];
if (!phase.setup)
continue;
float voltage = sdm_meter_get_float(SDM_PHASE_1_VOLTAGE * 2 + (i * 4));
float current = sdm_meter_get_float(SDM_PHASE_1_CURRENT * 2 + (i * 4));
float active_power = sdm_meter_get_float(SDM_PHASE_1_ACTIVE_POWER * 2 + (i * 4));
float apparent_power = sdm_meter_get_float(SDM_PHASE_1_APPARENT_POWER * 2 + (i * 4));
float reactive_power = sdm_meter_get_float(SDM_PHASE_1_REACTIVE_POWER * 2 + (i * 4));
float power_factor = sdm_meter_get_float(SDM_PHASE_1_POWER_FACTOR * 2 + (i * 4));
float phase_angle = sdm_meter_get_float(SDM_PHASE_1_ANGLE * 2 + (i * 4));
ESP_LOGD(
TAG,
"SDMMeter Phase %c: V=%.3f V, I=%.3f A, Active P=%.3f W, Apparent P=%.3f VA, Reactive P=%.3f var, PF=%.3f, "
"PA=%.3f °",
i + 'A', voltage, current, active_power, apparent_power, reactive_power, power_factor, phase_angle);
if (phase.voltage_sensor_ != nullptr)
phase.voltage_sensor_->publish_state(voltage);
if (phase.current_sensor_ != nullptr)
phase.current_sensor_->publish_state(current);
if (phase.active_power_sensor_ != nullptr)
phase.active_power_sensor_->publish_state(active_power);
if (phase.apparent_power_sensor_ != nullptr)
phase.apparent_power_sensor_->publish_state(apparent_power);
if (phase.reactive_power_sensor_ != nullptr)
phase.reactive_power_sensor_->publish_state(reactive_power);
if (phase.power_factor_sensor_ != nullptr)
phase.power_factor_sensor_->publish_state(power_factor);
if (phase.phase_angle_sensor_ != nullptr)
phase.phase_angle_sensor_->publish_state(phase_angle);
publish(SDM_PHASE_1_VOLTAGE + i * 2, phase.voltage_sensor_);
publish(SDM_PHASE_1_CURRENT + i * 2, phase.current_sensor_);
publish(SDM_PHASE_1_ACTIVE_POWER + i * 2, phase.active_power_sensor_);
publish(SDM_PHASE_1_APPARENT_POWER + i * 2, phase.apparent_power_sensor_);
publish(SDM_PHASE_1_REACTIVE_POWER + i * 2, phase.reactive_power_sensor_);
publish(SDM_PHASE_1_POWER_FACTOR + i * 2, phase.power_factor_sensor_);
publish(SDM_PHASE_1_ANGLE + i * 2, phase.phase_angle_sensor_);
}
float total_power = sdm_meter_get_float(SDM_TOTAL_SYSTEM_POWER * 2);
float frequency = sdm_meter_get_float(SDM_FREQUENCY * 2);
float import_active_energy = sdm_meter_get_float(SDM_IMPORT_ACTIVE_ENERGY * 2);
float export_active_energy = sdm_meter_get_float(SDM_EXPORT_ACTIVE_ENERGY * 2);
float import_reactive_energy = sdm_meter_get_float(SDM_IMPORT_REACTIVE_ENERGY * 2);
float export_reactive_energy = sdm_meter_get_float(SDM_EXPORT_REACTIVE_ENERGY * 2);
ESP_LOGD(TAG, "SDMMeter: F=%.3f Hz, Im.A.E=%.3f Wh, Ex.A.E=%.3f Wh, Im.R.E=%.3f VARh, Ex.R.E=%.3f VARh, T.P=%.3f W",
frequency, import_active_energy, export_active_energy, import_reactive_energy, export_reactive_energy,
total_power);
if (this->total_power_sensor_ != nullptr)
this->total_power_sensor_->publish_state(total_power);
if (this->frequency_sensor_ != nullptr)
this->frequency_sensor_->publish_state(frequency);
if (this->import_active_energy_sensor_ != nullptr)
this->import_active_energy_sensor_->publish_state(import_active_energy);
if (this->export_active_energy_sensor_ != nullptr)
this->export_active_energy_sensor_->publish_state(export_active_energy);
if (this->import_reactive_energy_sensor_ != nullptr)
this->import_reactive_energy_sensor_->publish_state(import_reactive_energy);
if (this->export_reactive_energy_sensor_ != nullptr)
this->export_reactive_energy_sensor_->publish_state(export_reactive_energy);
publish(SDM_TOTAL_SYSTEM_POWER, this->total_power_sensor_);
publish(SDM_FREQUENCY, this->frequency_sensor_);
publish(SDM_IMPORT_ACTIVE_ENERGY, this->import_active_energy_sensor_);
publish(SDM_EXPORT_ACTIVE_ENERGY, this->export_active_energy_sensor_);
publish(SDM_IMPORT_REACTIVE_ENERGY, this->import_reactive_energy_sensor_);
publish(SDM_EXPORT_REACTIVE_ENERGY, this->export_reactive_energy_sensor_);
}
void SDMMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); }
+2 -1
View File
@@ -55,7 +55,8 @@ class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevic
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void dump_config() override;
+31 -68
View File
@@ -1,84 +1,47 @@
#include "selec_meter.h"
#include "selec_meter_registers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::selec_meter {
namespace helpers = modbus::helpers;
static const char *const TAG = "selec_meter";
static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers
void SelecMeter::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < MODBUS_REGISTER_COUNT * 2) {
ESP_LOGW(TAG, "Invalid size for SelecMeter!");
return;
}
void SelecMeter::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
auto selec_meter_get_float = [&](size_t i, float unit) -> float {
uint32_t temp = encode_uint32(data[i + 2], data[i + 3], data[i], data[i + 1]);
float f;
memcpy(&f, &temp, sizeof(f));
return (f * unit);
// Publish a sensor if both of its registers are in this response; skipping absent registers keeps
// this correct for any read range, so the poll may be split into multiple requests.
// Values are 32-bit floats, low word first.
auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::FP32_R>(registers, start_address, reg))
sensor->publish_state(*value * unit);
};
float total_active_energy = selec_meter_get_float(SELEC_TOTAL_ACTIVE_ENERGY * 2, NO_DEC_UNIT);
float import_active_energy = selec_meter_get_float(SELEC_IMPORT_ACTIVE_ENERGY * 2, NO_DEC_UNIT);
float export_active_energy = selec_meter_get_float(SELEC_EXPORT_ACTIVE_ENERGY * 2, NO_DEC_UNIT);
float total_reactive_energy = selec_meter_get_float(SELEC_TOTAL_REACTIVE_ENERGY * 2, NO_DEC_UNIT);
float import_reactive_energy = selec_meter_get_float(SELEC_IMPORT_REACTIVE_ENERGY * 2, NO_DEC_UNIT);
float export_reactive_energy = selec_meter_get_float(SELEC_EXPORT_REACTIVE_ENERGY * 2, NO_DEC_UNIT);
float apparent_energy = selec_meter_get_float(SELEC_APPARENT_ENERGY * 2, NO_DEC_UNIT);
float active_power = selec_meter_get_float(SELEC_ACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT);
float reactive_power = selec_meter_get_float(SELEC_REACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT);
float apparent_power = selec_meter_get_float(SELEC_APPARENT_POWER * 2, MULTIPLY_THOUSAND_UNIT);
float voltage = selec_meter_get_float(SELEC_VOLTAGE * 2, NO_DEC_UNIT);
float current = selec_meter_get_float(SELEC_CURRENT * 2, NO_DEC_UNIT);
float power_factor = selec_meter_get_float(SELEC_POWER_FACTOR * 2, NO_DEC_UNIT);
float frequency = selec_meter_get_float(SELEC_FREQUENCY * 2, NO_DEC_UNIT);
float maximum_demand_active_power =
selec_meter_get_float(SELEC_MAXIMUM_DEMAND_ACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT);
float maximum_demand_reactive_power =
selec_meter_get_float(SELEC_MAXIMUM_DEMAND_REACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT);
float maximum_demand_apparent_power =
selec_meter_get_float(SELEC_MAXIMUM_DEMAND_APPARENT_POWER * 2, MULTIPLY_THOUSAND_UNIT);
if (this->total_active_energy_sensor_ != nullptr)
this->total_active_energy_sensor_->publish_state(total_active_energy);
if (this->import_active_energy_sensor_ != nullptr)
this->import_active_energy_sensor_->publish_state(import_active_energy);
if (this->export_active_energy_sensor_ != nullptr)
this->export_active_energy_sensor_->publish_state(export_active_energy);
if (this->total_reactive_energy_sensor_ != nullptr)
this->total_reactive_energy_sensor_->publish_state(total_reactive_energy);
if (this->import_reactive_energy_sensor_ != nullptr)
this->import_reactive_energy_sensor_->publish_state(import_reactive_energy);
if (this->export_reactive_energy_sensor_ != nullptr)
this->export_reactive_energy_sensor_->publish_state(export_reactive_energy);
if (this->apparent_energy_sensor_ != nullptr)
this->apparent_energy_sensor_->publish_state(apparent_energy);
if (this->active_power_sensor_ != nullptr)
this->active_power_sensor_->publish_state(active_power);
if (this->reactive_power_sensor_ != nullptr)
this->reactive_power_sensor_->publish_state(reactive_power);
if (this->apparent_power_sensor_ != nullptr)
this->apparent_power_sensor_->publish_state(apparent_power);
if (this->voltage_sensor_ != nullptr)
this->voltage_sensor_->publish_state(voltage);
if (this->current_sensor_ != nullptr)
this->current_sensor_->publish_state(current);
if (this->power_factor_sensor_ != nullptr)
this->power_factor_sensor_->publish_state(power_factor);
if (this->frequency_sensor_ != nullptr)
this->frequency_sensor_->publish_state(frequency);
if (this->maximum_demand_active_power_sensor_ != nullptr)
this->maximum_demand_active_power_sensor_->publish_state(maximum_demand_active_power);
if (this->maximum_demand_reactive_power_sensor_ != nullptr)
this->maximum_demand_reactive_power_sensor_->publish_state(maximum_demand_reactive_power);
if (this->maximum_demand_apparent_power_sensor_ != nullptr)
this->maximum_demand_apparent_power_sensor_->publish_state(maximum_demand_apparent_power);
publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT);
publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT);
publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT);
publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT);
publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT);
publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT);
}
void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); }
+2 -1
View File
@@ -37,7 +37,8 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev
void update() override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
void dump_config() override;
};
@@ -9,6 +9,7 @@ from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
add_partition,
include_builtin_idf_component,
require_vfs_select,
)
import esphome.config_validation as cv
@@ -288,6 +289,10 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
ref="2.0.4",
)
if CONF_WIFI in CORE.config:
# zigbee_esp32.cpp uses esp_coexist.h when WiFi is present
include_builtin_idf_component("esp_coex")
# add sdkconfigs later so they can overwrite esp32 defaults
CORE.add_job(_zigbee_add_sdkconfigs, config)
+7 -2
View File
@@ -783,7 +783,8 @@ class EsphomeCore:
can compare a locally computed hash against the one a device
advertises. Machine-local data is kept out of the input: build_path
(which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded,
and Path values are dumped relative to the config directory.
and Path values are dumped relative to the config directory, with
the data directory always at its default ``.esphome`` location.
"""
if self._config_hash is None:
from esphome import yaml_util
@@ -794,11 +795,15 @@ class EsphomeCore:
esphome_conf = dict(esphome_conf)
esphome_conf.pop(CONF_BUILD_PATH, None)
config[CONF_ESPHOME] = esphome_conf
relative_to = data_dir = None
if self.config_path is not None:
relative_to, data_dir = self.config_dir, self.data_dir
config_str = yaml_util.dump(
config,
show_secrets=True,
sort_keys=True,
relative_to=self.config_dir if self.config_path is not None else None,
relative_to=relative_to,
data_dir=data_dir,
)
self._config_hash = fnv1a_32bit_hash(config_str)
return self._config_hash
+1 -3
View File
@@ -336,10 +336,8 @@ void log_update_interval(const char *tag, PollingComponent *component) {
uint32_t update_interval = component->get_update_interval();
if (update_interval == SCHEDULER_DONT_RUN) {
ESP_LOGCONFIG(tag, " Update Interval: never");
} else if (update_interval < 100) {
ESP_LOGCONFIG(tag, " Update Interval: %.3fs", update_interval / 1000.0f);
} else {
ESP_LOGCONFIG(tag, " Update Interval: %.1fs", update_interval / 1000.0f);
ESP_LOGCONFIG(tag, " Update Interval: %" PRIu32 ".%03" PRIu32 "s", update_interval / 1000, update_interval % 1000);
}
}
float Component::get_actual_setup_priority() const {
+3
View File
@@ -134,6 +134,7 @@
#define MDNS_DYNAMIC_TXT_COUNT 2
#define MICRONOVA_LISTENER_COUNT 1
#define USE_MICRONOVA_WRITER
#define MK2PVROUTER_LISTENER_COUNT 1
#define SERIAL_PROXY_COUNT 2
#define SNTP_SERVER_COUNT 3
#define USE_MEDIA_PLAYER
@@ -217,9 +218,11 @@
#define USE_API_PLAINTEXT
#define USE_API_USER_DEFINED_ACTIONS
#define USE_API_CUSTOM_SERVICES
#define USE_API_USER_DEFINED_ACTION_METADATA
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define API_MAX_SEND_QUEUE 8
#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64
#define MAX_API_CONNECTIONS 6
// The Improv library is not in the Zephyr tidy environment
#define USE_IMPROV_SERIAL
+24 -10
View File
@@ -568,7 +568,7 @@ size_t value_accuracy_to_buf(std::span<char, VALUE_ACCURACY_MAX_LEN> buf, float
}
// Fallback for NaN/Inf/high accuracy/out-of-range
int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value);
int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, static_cast<double>(value));
if (len < 0)
return 0;
return static_cast<size_t>(len) >= buf.size() ? buf.size() - 1 : static_cast<size_t>(len);
@@ -586,16 +586,30 @@ size_t value_accuracy_with_uom_to_buf(std::span<char, VALUE_ACCURACY_MAX_LEN> bu
}
int8_t step_to_accuracy_decimals(float step) {
// use printf %g to find number of digits based on temperature step
char buf[32];
snprintf(buf, sizeof buf, "%.5g", step);
std::string str{buf};
size_t dot_pos = str.find('.');
if (dot_pos == std::string::npos)
// Decimals needed to show the step at five significant digits, trailing zeros dropped.
if (!std::isfinite(step) || step == 0.0f)
return 0;
return str.length() - dot_pos - 1;
float mantissa = std::fabs(step);
int8_t decimals = 4; // decimals needed for five significant digits when mantissa is in [1, 10)
while (mantissa >= 10.0f) {
mantissa /= 10.0f;
decimals--;
}
while (mantissa < 1.0f) {
mantissa *= 10.0f;
decimals++;
}
if (decimals <= 0)
return 0;
float scaled = mantissa * 10000.0f;
auto digits = static_cast<uint32_t>(scaled);
if (scaled - static_cast<float>(digits) >= 0.5f)
digits++;
while (decimals > 0 && digits % 10 == 0) {
digits /= 10;
decimals--;
}
return decimals;
}
// Map a base64/base64url character to its 6-bit value (0-63) arithmetically.
+1
View File
@@ -290,6 +290,7 @@ template<typename T, size_t N> class StaticVector {
}
size_t size() const { return count_; }
static constexpr size_t capacity() { return N; }
bool empty() const { return count_ == 0; }
// Direct access to underlying data
+1
View File
@@ -14,6 +14,7 @@ std_string_ref = std_ns.namespace("string &")
std_vector = std_ns.class_("vector")
std_span = std_ns.class_("span")
int8 = global_ns.namespace("int8_t")
char = global_ns.namespace("char")
uint8 = global_ns.namespace("uint8_t")
uint16 = global_ns.namespace("uint16_t")
uint32 = global_ns.namespace("uint32_t")
+24 -9
View File
@@ -1053,15 +1053,28 @@ def _check_esp_idf_python_env_install(
constraint_file_path,
)
cmd_pip_install = [
str(env_python_path),
"-m",
"pip",
"install",
"--upgrade",
"--constraint",
constraint_file_path,
]
# uv (much faster than pip) when available, e.g. in the docker image
if uv_path := shutil.which("uv"):
cmd_pip_install = [
uv_path,
"pip",
"install",
"--python",
str(env_python_path),
"--upgrade",
"--constraint",
str(constraint_file_path),
]
else:
cmd_pip_install = [
str(env_python_path),
"-m",
"pip",
"install",
"--upgrade",
"--constraint",
str(constraint_file_path),
]
_LOGGER.info("Installing ESP-IDF %s Python dependencies ...", version)
cmd = cmd_pip_install + [
@@ -1135,6 +1148,8 @@ def check_esp_idf_install(
env = {}
env["IDF_TOOLS_PATH"] = str(get_idf_tools_path())
env["IDF_PATH"] = ""
# uv defaults to 3 HTTP retries; match the pioarduino penv's bump to 10
env["UV_HTTP_RETRIES"] = os.environ.get("UV_HTTP_RETRIES", "10")
# An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's
# per-variant request (builder-image pre-warm); otherwise the caller's
+40 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterable, MutableMapping
from collections.abc import Callable, Iterable, MutableMapping
from contextlib import suppress
import ipaddress
import logging
@@ -456,23 +456,55 @@ def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) ->
env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts)
def rmtree(path: Path | str) -> None:
"""Remove a directory tree, handling read-only files on Windows.
# Deletion attempts when a directory keeps being repopulated mid-delete
RMTREE_MAX_ATTEMPTS = 3
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail. This handles that by removing the
read-only flag and retrying.
def rmtree(path: Path | str) -> None:
"""Remove a directory tree, tolerating common filesystem races.
Read-only files (e.g. git pack files on Windows) get the read-only flag
removed and are retried. Paths that are already gone, whether the target
itself or entries vanishing mid-delete, are treated as removed.
Directories repopulated mid-delete (e.g. Finder recreating .DS_Store on
macOS) are retried a few times.
"""
import errno
import shutil
import time
def _onexc(func, path, exc):
def _onexc(func: Callable[..., object], path: str | Path, exc: OSError) -> None:
if isinstance(exc, FileNotFoundError):
_LOGGER.debug("rmtree: %s already gone", path)
return
if os.access(path, os.W_OK):
raise exc
Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR)
func(path)
shutil.rmtree(path, onexc=_onexc)
last_err: OSError | None = None
for attempt in range(RMTREE_MAX_ATTEMPTS - 1):
try:
shutil.rmtree(path, onexc=_onexc)
return
except OSError as err:
if err.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
_LOGGER.debug(
"rmtree: %s repopulated mid-delete (attempt %d): %s",
path,
attempt + 1,
err,
)
last_err = err
# Give the racing writer (e.g. Finder) time to settle
time.sleep(0.05 * (attempt + 1))
try:
shutil.rmtree(path, onexc=_onexc)
except OSError as err:
# Keep the earlier races visible in the traceback
raise err from last_err
def walk_files(path: Path):
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies:
espressif/esp32-camera:
version: 2.1.7
espressif/mdns:
version: 1.11.3
version: 1.12.0
espressif/esp_wifi_remote:
version: 1.6.3
rules:
+107 -8
View File
@@ -74,6 +74,11 @@ SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
".ASM": "asm",
}
SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX)
# Suffixes that count as headers when probing whether a library has any
# usable files at all (compare against Path.suffix.lower())
LIBRARY_HEADER_SUFFIXES = frozenset(
{".h", ".hpp", ".hh", ".hxx", ".inc", ".ipp", ".tcc"}
)
DOMAIN = "pio_components"
@@ -329,6 +334,11 @@ class LibraryBackend:
framework: str
emit: Callable[["ConvertedLibrary"], None]
cache_key: str
# Owner-less names this returns True for are skipped by the walk;
# the backend supplies them itself (e.g. core-bundled libraries) and
# reconciles provided_requests after resolving
provides: Callable[[str], bool] | None = None
provided_requests: set[str] = field(default_factory=set)
def ensure_list[T](obj: T | list[T]) -> list[T]:
@@ -469,7 +479,7 @@ def _valid_manifest_shape(data: Any) -> bool:
)
def check_library_data(data: dict, platform: str | None, framework: str):
def check_library_data(data: dict, platform: str | None, framework: str | None):
"""
Check whether a library manifest is compatible with the target toolchain.
@@ -486,7 +496,8 @@ def check_library_data(data: dict, platform: str | None, framework: str):
for targets (e.g. Zephyr) where PIO manifests rarely declare the
platform yet portable libraries still build.
framework: The active framework name (e.g. ``espidf``, ``arduino``,
``zephyr``) the manifest is expected to declare.
``zephyr``) the manifest is expected to declare. ``None`` skips
the framework check (and its warning), mirroring ``platform``.
Raises:
InvalidLibrary: If the library does not support the target platform.
@@ -517,7 +528,7 @@ def check_library_data(data: dict, platform: str | None, framework: str):
# under the target framework, and there's no way to opt out of the check at
# this layer. Warn instead of failing so the user isn't forced to fork the
# library to fix the manifest.
valid_framework = "*" in frameworks or framework in frameworks
valid_framework = framework is None or "*" in frameworks or framework in frameworks
if not valid_framework:
_LOGGER.warning(
@@ -576,11 +587,15 @@ def _make_registry_client() -> Any:
elsewhere, not by the PlatformIO registry.
"""
from platformio.package.manager._registry import PackageManagerRegistryMixin
from platformio.registry.client import RegistryClient
class _Registry(PackageManagerRegistryMixin):
def __init__(self) -> None:
self._registry_client = None
self.pkg_type = "library"
self._registry_client = RegistryClient()
# The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages);
# instance-level so the ESPHome process never patches PlatformIO's class
self._registry_client.allowed_private_packages = lambda: False
@staticmethod
def is_system_compatible(value: Any, custom_system: Any = None) -> bool:
@@ -914,6 +929,56 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
)
def _reconcile_versionless_skips(
skipped_versionless: list[tuple[Any, Any, str]],
components: dict[str, ConvertedLibrary],
backend: LibraryBackend,
) -> None:
"""Warn for version-less deps nothing satisfied, and record the
backend-provided ones in ``backend.provided_requests`` for its
post-emit reconciliation; a silent drop surfaces as link errors far
from the cause."""
resolved_manifest_names = {c.data.get("name") for c in components.values()}
# A treeless backend can never supply a bundled name; noise for it
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug
warned: set[str] = set()
for dep_name, dep_owner, requester in skipped_versionless:
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
continue
if dep_name in components:
# A version-less dep's request key is the name itself
continue
if (
not dep_owner
and backend.provides is not None
and backend.provides(dep_name)
):
# provides() only satisfies owner-less names (same guard as
# the walk's skip); record for the post-emit reconciliation.
# Checked before the manifest-name evidence so the overlap
# case warns once, in the backend's own suppression loop
backend.provided_requests.add(dep_name)
continue
if dep_name in resolved_manifest_names:
# Name-only evidence: a coincidental collision must stay
# visible where the user could pin it
warned.add(dep_name)
log(
"Version-less dependency %s of %s assumed satisfied by a "
"resolved library's manifest name only",
dep_name,
requester,
)
continue
warned.add(dep_name)
log(
"Dependency %s of %s has no version to resolve and nothing "
"provides it; skipping",
dep_name,
requester,
)
def _fetch_source(
component: ConvertedLibrary,
salt: str,
@@ -1083,6 +1148,8 @@ def convert_libraries(
components: dict[str, ConvertedLibrary] = {}
resolved_requirements: dict[str, frozenset[str]] = {}
top_level_keys = set(top_level)
# (name, owner, requester) reconciled against the final resolution set
skipped_versionless: list[tuple[Any, Any, str]] = []
worklist = deque(dict.fromkeys(top_level))
while worklist:
# Drain the frontier sequentially (spec resolution mutates shared
@@ -1187,13 +1254,23 @@ def convert_libraries(
component.data.get("dependencies"), component.name
):
if "version" not in dependency:
# Cannot resolve from the registry; common for bundled
# names (Wire, SPI) -- unactionable noise above debug
# Cannot resolve from the registry; the post-emit
# reconciliation owns the drop warning
dep_name = dependency.get("name")
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
dep_name,
component.name,
)
if not is_lib_ignored(
dep_name, lib_ignore
) and dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
):
# Filtered or ignored deps are deliberately absent
skipped_versionless.append(
(dep_name, dependency.get("owner"), component.name)
)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
@@ -1205,11 +1282,31 @@ def convert_libraries(
if is_lib_ignored(dep_name, lib_ignore):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
# The version may be a URL (git/archive), which names one
# specific source; never substitute a bundled library for it
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
elif (
backend.provides is not None
and not dependency.get("owner")
and backend.provides(dep_name)
):
# The backend adds it from its own tree; resolving here
# would fetch a same-named registry package
if dep_version and dep_version != "*":
# The pin is discarded; make the substitution visible
_LOGGER.warning(
"Dependency %s pins version %s; using the library "
"bundled with the framework instead",
dep_name,
dep_version,
)
else:
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
backend.provided_requests.add(dep_name)
continue
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
@@ -1263,4 +1360,6 @@ def convert_libraries(
for component in components.values():
backend.emit(component)
_reconcile_versionless_skips(skipped_versionless, components, backend)
return [components[key] for key in top_level if key in components]
+2
View File
@@ -922,8 +922,10 @@ def main(argv: list[str]) -> int:
"""Subprocess entry point: ``prefetch <build_dir> <env_name>``."""
from esphome.core import CORE
from esphome.log import setup_log
from esphome.platformio.runner import patch_registry_private_packages
signal.signal(signal.SIGTERM, _sigterm)
patch_registry_private_packages()
raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL")
try:
level = int(raw_level) if raw_level is not None else logging.INFO
+13 -1
View File
@@ -2,7 +2,8 @@
Invoked via ``python -m esphome.platformio.runner`` instead of
``python -m platformio`` so that the patches (incremental rebuild
preservation, download retries) apply inside the subprocess. Running
preservation, download retries, skipping the private-package probe) apply
inside the subprocess. Running
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
global state from leaking into the ESPHome process.
"""
@@ -105,6 +106,16 @@ def patch_file_downloader() -> None:
FileDownloader.__init__ = patched_init
def patch_registry_private_packages() -> None:
"""Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup.
ESPHome never uses private packages, so the answer is always False.
"""
from platformio.registry.client import RegistryClient
RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign]
_IGNORE_LIB_WARNINGS = "(?:Hash|Update)"
# Regex patterns matched against each line of PlatformIO output. Lines that
# match are dropped by RedirectText before they reach the parent process.
@@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [
def main() -> int:
patch_structhash()
patch_file_downloader()
patch_registry_private_packages()
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
#
+20 -2
View File
@@ -1057,11 +1057,19 @@ def _load_yaml_internal_with_type(
loader.dispose()
def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None):
def dump(
dict_,
show_secrets=False,
sort_keys=False,
relative_to: Path | None = None,
data_dir: Path | None = None,
):
"""Dump YAML to a string and remove null.
When ``relative_to`` is given, Path values are dumped relative to that
directory (POSIX form) so the output is machine independent.
directory (POSIX form) so the output is machine independent; Path values
under ``data_dir`` are then dumped as ``.esphome/<rest>``. ``data_dir``
has no effect unless ``relative_to`` is also given.
"""
if show_secrets:
_SECRET_VALUES.clear()
@@ -1073,6 +1081,7 @@ def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None =
class _Dumper(ESPHomeDumper):
_redact_sensitive = not show_secrets
_relative_to = relative_to
_data_dir = data_dir
return yaml.dump(
dict_,
@@ -1231,6 +1240,9 @@ class ESPHomeDumper(yaml.SafeDumper):
# directory (in POSIX form) so the output does not depend on where the
# config lives on the machine that produced it.
_relative_to: Path | None = None
# Paths under this directory are dumped as ``.esphome/<rest>`` so the
# add-on's ``/data`` mount matches the CLI layout.
_data_dir: Path | None = None
def represent_mapping(self, tag, mapping, flow_style=None):
value = []
@@ -1274,6 +1286,12 @@ class ESPHomeDumper(yaml.SafeDumper):
# path that still cannot be relativized (e.g. a different drive)
# keeps its POSIX form so separators stay stable across OSes.
path = Path(os.path.normpath(value))
# Checked first: the default data dir sits inside the config dir.
if self._data_dir is not None and path.is_relative_to(
data_dir := os.path.normpath(self._data_dir)
):
rel = Path(".esphome") / path.relative_to(data_dir)
return self.represent_stringify(rel.as_posix())
with suppress(ValueError):
path = path.relative_to(
os.path.normpath(self._relative_to), walk_up=True
+2 -2
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==46.2.1
aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
@@ -20,7 +20,7 @@ ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.3.0
resvg-py==0.4.0
resvg-py==0.5.0
freetype-py==2.5.1
jinja2==3.1.6
bleak==3.0.2
+48 -9
View File
@@ -1,13 +1,10 @@
"""Files that affect clang-tidy results, and a content hash over them.
"""Files that affect clang-tidy results and the idedata built from them.
``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single
source of truth for which files influence clang-tidy output. A change to any of
them can surface warnings in source files a PR didn't touch, so:
* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and
* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by
``script/helpers.py`` (a content hash, unlike an mtime check, stays correct
across git checkouts).
``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) lists the files
that influence clang-tidy output; ``script/determine-jobs.py`` runs a full scan
when one changes. ``ESP_IDF_INFRA_TRIGGER_*`` lists the native ESP-IDF build
code. ``idedata_cache_hash()`` folds the right set into the idedata cache key
used by ``script/helpers.py`` and the CI cache action.
"""
from __future__ import annotations
@@ -31,6 +28,18 @@ CLANG_TIDY_GLOBAL_FILES = (
# this prefix at the repo root.
SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults"
# Native ESP-IDF build infra: determine-jobs forces an esp32 compile when these
# change, and they feed the clang-tidy idedata cache key.
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/espidf.py",
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
def read_file_bytes(path: Path) -> bytes:
"""Read bytes from a file."""
@@ -66,3 +75,33 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str:
hasher.update(read_file_bytes(path))
return hasher.hexdigest()
def calculate_idedata_cache_hash(repo_root: Path | None = None) -> str:
"""Clang-tidy hash plus the Python that generates the idedata."""
repo_root = _ensure_repo_root(repo_root)
hasher = hashlib.sha256()
hasher.update(calculate_clang_tidy_hash(repo_root).encode())
paths = {repo_root / name for name in ESP_IDF_INFRA_TRIGGER_FILES}
for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES:
# .pyc files appear between the CI key computation and load_idedata's.
paths.update(
path
for path in (repo_root / prefix).rglob("*")
if "__pycache__" not in path.parts
)
for path in sorted(paths):
if path.is_file():
hasher.update(str(path.relative_to(repo_root)).encode())
hasher.update(read_file_bytes(path))
return hasher.hexdigest()
def idedata_cache_hash(environment: str, repo_root: Path | None = None) -> str:
"""Hash gating the cached idedata of one clang-tidy environment."""
if "esp32" in environment:
return calculate_idedata_cache_hash(repo_root)
return calculate_clang_tidy_hash(repo_root)
+25 -20
View File
@@ -58,7 +58,12 @@ from pathlib import Path
import sys
from typing import Any
from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX
from clang_tidy_hash import (
CLANG_TIDY_GLOBAL_FILES,
ESP_IDF_INFRA_TRIGGER_FILES,
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES,
SDKCONFIG_DEFAULTS_PREFIX,
)
from helpers import (
CPP_FILE_EXTENSIONS,
ESPHOME_TESTS_COMPONENTS_PATH,
@@ -96,6 +101,17 @@ COMPONENT_TEST_BATCH_SIZE = 40
INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
INTEGRATION_TESTS_SPLIT_BUCKETS = 3
# platformio and aioesphomeapi (requirements.txt), the pytest stack
# (requirements_test.txt) and the fixture every session compiles; a change
# to any runs the full matrix
INTEGRATION_TESTS_TRIGGER_FILES = frozenset(
{
"requirements.txt",
"requirements_test.txt",
"tests/integration/fixtures/cache_init.yaml",
}
)
def _split_list(items: list[str], n: int) -> list[list[str]]:
"""Split a list into n roughly-equal contiguous parts (matches script/clang-tidy)."""
@@ -216,12 +232,15 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
3. Integration test infrastructure files changed
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed
- The dependency pins and the session init fixture affect every test
Returns (run_all=False, [test_files...]) when:
4. Specific integration test files changed
5. Specific integration test files changed
- Only those specific test files are returned
5. Components used by integration tests (or their dependencies) changed
6. Components used by integration tests (or their dependencies) changed
- Only test files whose fixtures use the changed components are returned
Args:
@@ -239,6 +258,9 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
# If any core files changed, run all integration tests
return (True, [])
if any(f in INTEGRATION_TESTS_TRIGGER_FILES for f in files):
return (True, [])
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
if any(
@@ -524,23 +546,6 @@ def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
return False
# Native-build infra: changes under esphome/espidf/, the shared
# esphome/build_helpers/ package, or the modules the native ESP-IDF build
# imports affect every esp32 IDF build (now the default toolchain) but aren't
# components, so the component matrix wouldn't otherwise force any esp32
# compile. When they change we fold the `esp32` component into the matrix so
# the default native-IDF build path is still compiled on an infra-only PR.
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/espidf.py",
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
def _esp_idf_infra_changed(files: list[str]) -> bool:
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
for file in files:
+4 -7
View File
@@ -809,17 +809,14 @@ def load_idedata(environment: str) -> dict[str, Any]:
start_time = time.time()
print(f"Loading IDE data for environment '{environment}'...")
# Reuse the clang-tidy input hash as the cache key: it already covers every
# file baked into the generated idedata (platformio.ini, sdkconfig.defaults,
# esphome/idf_component.yml), so this can't drift from that file list. A
# content hash -- unlike an mtime comparison -- stays correct across git
# checkouts, which don't preserve mtimes.
from clang_tidy_hash import calculate_clang_tidy_hash
# Content hash of the idedata inputs (data files and the generator code); a
# content hash, unlike mtimes, stays correct across git checkouts.
from clang_tidy_hash import idedata_cache_hash
temp_idedata = Path(temp_folder) / f"idedata-{environment}.json"
temp_hash = Path(temp_folder) / f"idedata-{environment}.hash"
cache_key = calculate_clang_tidy_hash()
cache_key = idedata_cache_hash(environment)
changed = (
not temp_idedata.is_file()
or not temp_hash.is_file()
@@ -0,0 +1,155 @@
"""Tests for user-defined action field metadata (description / example)."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.api import (
_action_strings,
_action_strings_size,
_has_action_metadata,
_validate_esp8266_action_strings,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.cpp_generator import safe_exp
from esphome.helpers import fnv1_hash
from tests.component_tests.helpers import get_define_value
from tests.component_tests.types import SetCoreConfigCallable
CONFIG = "tests/component_tests/api/test_action_metadata.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml"
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
def test_metadata_is_emitted_as_progmem_table(
generate_main: Callable[[str | Path], str],
) -> None:
"""Every action string is a PROGMEM array referenced from one PROGMEM table."""
main_cpp = generate_main(CONFIG)
assert (
'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp
)
assert (
'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";'
in main_cpp
)
assert (
'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";'
in main_cpp
)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
"api_action_str0, api_action_str1, api_action_str2, api_action_str3, "
"api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp
)
# An action without metadata still carries the metadata slots (as nullptr)
assert (
"static constexpr const char * api_action1_strings[] PROGMEM = {"
"api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp
)
assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp
assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines}
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None
def test_esp8266_sizes_scratch_buffer_for_largest_action(
generate_main: Callable[[str | Path], str],
) -> None:
"""ESP8266 gets a scratch buffer define equal to the byte total of the largest action."""
generate_main(CONFIG_ESP8266)
# play_buzzer: name, description, two variable names, one description, one example,
# each with a terminator
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117"
def test_shorthand_variables_emit_no_metadata(
generate_main: Callable[[str | Path], str],
) -> None:
"""The name: type shorthand emits a name-only table and no define."""
main_cpp = generate_main(CONFIG_SHORTHAND)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = "
"{api_action_str0, api_action_str1};" in main_cpp
)
assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines}
def test_variable_shorthand_normalizes_to_mapping() -> None:
"""A bare type string validates to the mapping form."""
assert validate_variable("string") == {"type": "string"}
@pytest.mark.parametrize(
"value",
[
{"description": "no type given"},
{"type": "string", "selector": "text"},
"stringy",
{"type": "stringy"},
],
)
def test_variable_rejects_invalid(value: object) -> None:
"""Missing or unknown type and unknown keys raise in both forms."""
with pytest.raises(Invalid):
validate_variable(value)
def _oversized_action_config() -> dict:
return {
"actions": [
{
"action": "big",
"description": "x" * 300,
"variables": {"a": {"type": "string", "example": "y" * 300}},
}
]
}
def test_esp8266_rejects_actions_over_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"):
_validate_esp8266_action_strings(_oversized_action_config())
def test_other_platforms_have_no_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = _oversized_action_config()
assert _validate_esp8266_action_strings(config) is config
def test_empty_metadata_is_unset_and_not_counted() -> None:
"""An empty description or example emits nullptr and takes no scratch space."""
conf = {
"action": "a",
"description": "",
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
}
strings = _action_strings(conf, has_metadata=True)
assert strings == ["a", None, "b", None, "ex"]
# Every emitted string counts its terminator: "a" + "b" + "ex"
assert _action_strings_size(strings) == 2 + 2 + 3
def test_empty_metadata_does_not_enable_the_define() -> None:
actions = [
{
"action": "a",
"description": "",
"variables": {"b": {"type": "int", "example": ""}},
}
]
assert not _has_action_metadata(actions)
actions[0]["variables"]["b"]["example"] = "1"
assert _has_action_metadata(actions)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_metadata_common.yaml
@@ -0,0 +1,18 @@
api:
actions:
- action: play_buzzer
description: Play an RTTTL melody on the buzzer
variables:
song_str:
type: string
description: RTTTL melody string
example: "two_short:d=4,o=5,b=100:16e6,16e6"
volume:
type: int
then:
- logger.log: Action Called
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -0,0 +1,14 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_metadata_common.yaml
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
api:
actions:
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -9,7 +9,7 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main):
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
'("zero_copy_args", {"message"})' in main_cpp
"(api_action0_strings," in main_cpp
)
@@ -22,7 +22,7 @@ def test_response_callback_args_are_owning(generate_main):
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
'("response_args", {"message"})' in main_cpp
"(api_action1_strings," in main_cpp
)
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
espnow:
channel: 1
auto_add_peer: true
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sensor:
- platform: internal_temperature
name: Internal Temperature

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