Merge remote-tracking branch 'origin/dev' into store-yaml-helpers-core

# Conflicts:
#	esphome/yaml_util.py
This commit is contained in:
J. Nick Koston
2026-10-12 07:56:22 -10:00
881 changed files with 28239 additions and 6799 deletions
+26 -10
View File
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
`large side `;
if (tooManyLabels && tooManyChanges) {
message +=
@@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
}
message += `, which makes it harder for maintainers to review.\n\n`;
let condition;
if (tooManyLabels && tooManyChanges) {
condition =
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
`at most ${MAX_LABELS} components/areas`;
} else if (tooManyLabels) {
condition = `touches at most ${MAX_LABELS} components/areas`;
} else {
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
}
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
`**This PR will not be reviewed until it ${condition}.** We know ` +
`that is not what you want to hear after this much work, and we want ` +
`to be upfront so you do not wait for a review that is not coming: ` +
`please split the change. The label is removed automatically once ` +
`the PR ${condition}.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
`There is no exception you can request. Rarely, a maintainer agrees in ` +
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
`but that is rare enough that you should plan on splitting rather than ` +
`wait for it. Smaller, focused PRs are picked up much faster because ` +
`they fit into the short gaps between other maintainer work. For a new ` +
`component, the first PR brings the hub and a single platform (for ` +
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
@@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
}
module.exports = {
generateReviewMessages,
handleReviews,
handleMaintainerAccessComment
};
@@ -0,0 +1,38 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { generateReviewMessages } = require('../reviews');
const MAX_LABELS = 15;
const TOO_BIG_THRESHOLD = 1000;
function tooBigMessage(labelCount, additions) {
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
assert.equal(messages.length, 1);
return messages[0];
}
describe('too-big review message', () => {
it('names the line cap when the PR is too long', () => {
const message = tooBigMessage(3, 1500);
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
assert.doesNotMatch(message, /components\/areas\./);
});
it('names the area cap when the PR touches too many areas but is short', () => {
const message = tooBigMessage(20, 200);
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
assert.doesNotMatch(message, /until it is at most 1000 lines/);
});
it('names both caps when both are exceeded', () => {
const message = tooBigMessage(20, 1500);
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
});
it('tells new-component authors to bring one platform first', () => {
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
assert.match(tooBigMessage(3, 1500), /mega-pr/);
});
});
+86
View File
@@ -1335,6 +1335,91 @@ jobs:
# config first, so a separate config pass is redundant.
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
test-esp8266-native-windows:
name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }})
runs-on: windows-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
# A hung compiler must fail the job, not hold the runner for six hours.
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
# Both ways the build runs on Windows: ESPHome Desktop bundles
# ccache and puts it on PATH, a pip install has none.
ccache: ["1", "0"]
env:
ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }}
# The test script prints characters the cp1252 console cannot encode
PYTHONUTF8: "1"
# The exact static build ESPHome Desktop ships
CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip
CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Install ccache
if: matrix.ccache == '1'
shell: pwsh
run: |
$dir = Join-Path $env:RUNNER_TEMP "ccache"
New-Item -ItemType Directory -Force $dir | Out-Null
$zip = Join-Path $dir "ccache.zip"
Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if ($hash -ne $env:CCACHE_SHA256) {
Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256"
exit 1
}
Expand-Archive -Path $zip -DestinationPath $dir
$bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1
Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName
& $bin.FullName --version
- name: Compile
shell: pwsh
# One component test: web_server brings wifi, api and the async web
# server libraries, the shape of a typical ESP8266 device
run: |
. .\venv\Scripts\Activate.ps1
$arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests"
$p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru
# Reading the handle keeps ExitCode available after the process exits
$null = $p.Handle
if (-not $p.WaitForExit(25 * 60 * 1000)) {
Write-Host "::error::The compile did not finish in 25 minutes; processes still running:"
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } |
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
Format-List | Out-String -Width 4096 | Write-Host
Get-Process |
Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } |
Select-Object Id, Name, CPU, WorkingSet64 |
Format-Table | Out-String | Write-Host
taskkill /PID $p.Id /T /F
exit 1
}
exit $p.ExitCode
- name: Show ccache statistics
# Also after a failed or killed compile: the counters say whether
# ccache was ever reached
if: always() && matrix.ccache == '1'
shell: pwsh
# The build keeps its store under the esphome tools cache
run: |
$env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache"
ccache -s
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
@@ -1705,6 +1790,7 @@ jobs:
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- test-esp8266-native-windows
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
+15 -1
View File
@@ -16,16 +16,18 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-stale-conflicting: 30
days-before-close: 7
stale-label: stale
exempt-label: not-stale
conflict-label: needs-rebase
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
@@ -36,6 +38,18 @@ jobs:
to keep it open. Also, please merge the latest dev branch into your
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
conflict-pr-message: >
This pull request has merge conflicts. Please merge the latest dev
branch into your branch to resolve them.
stale-conflicting-pr-message: >
The merge conflicts on this pull request have not been resolved for
30 days, so it has been marked as stale and will be closed if they
are not resolved within 7 days.
Please merge the latest dev branch into your branch. Once the pull
request is mergeable again the stale label is removed.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
+43
View File
@@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
@@ -177,6 +178,8 @@ file does, and it is the authority when they disagree. The most useful starting
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
* **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm
rebuilds the callback and re-queues a scheduler item, and the period drifts.
* **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a
`static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other
components, so number them from 0 and keep all of a component's ids together in one place.
@@ -191,6 +194,18 @@ file does, and it is the authority when they disagree. The most useful starting
// Bad - this is exactly what the base class already does
float get_setup_priority() const override { return setup_priority::DATA; }
```
* **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope
constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the
table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`);
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
runtime check of something the build already knows.
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
can be stored in flash rather than RAM.
```cpp
@@ -396,6 +411,25 @@ file does, and it is the authority when they disagree. The most useful starting
)
```
When the callback's parameters differ from the automation's arguments, or the trigger should only
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
`forward` from `MockObj` calls, not f-strings of C++:
```python
parent = automation.parent_ref(var)
index = cg.RawExpression("index")
await automation.build_callback_automation(
var,
"add_on_state_callback",
[(cg.StringRef, "x"), (cg.size_t, "i")],
conf,
params=[(cg.size_t, "index")],
forward=[cg.StringRef(parent.option_at(index)), index],
)
```
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
```cpp
class MyComponent : public Component {
@@ -515,6 +549,7 @@ file does, and it is the authority when they disagree. The most useful starting
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
* **Configuration Validation:**
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
@@ -550,6 +585,13 @@ file does, and it is the authority when they disagree. The most useful starting
* **Testing:**
* **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis.
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`).
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
on. A variant per board for platform-identical code only adds CI time.
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
@@ -596,6 +638,7 @@ file does, and it is the authority when they disagree. The most useful starting
name: My Sensor
```
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
+1 -2
View File
@@ -347,7 +347,6 @@ esphome/components/mcp4461/* @p1ngb4ck
esphome/components/mcp4728/* @berfenger
esphome/components/mcp47a1/* @jesserockz
esphome/components/mcp9600/* @mreditor97
esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
@@ -570,7 +569,7 @@ esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax
esphome/components/teleinfo/* @0hax @FredM67
esphome/components/tem3200/* @bakerkj
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
esphome/components/template/datetime/* @rfdarter
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+164 -15
View File
@@ -48,9 +48,13 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
PLATFORM_HOST,
SECRETS_FILES,
ErrorFormat,
Toolchain,
)
from esphome.core import CORE, EsphomeError, coroutine
@@ -69,6 +73,7 @@ from esphome.util import (
get_serial_ports,
is_picotool_usb_permission_error,
list_yaml_files,
read_secret_line,
run_external_command,
run_external_process,
safe_print,
@@ -1231,14 +1236,28 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
port_type = get_port_type(host)
ota_key = getattr(args, "ota_key", None)
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
host,
)
platform_upload = platform_hooks.get_platform_hook(
CORE.target_platform, "upload_program"
)
if platform_upload is not None and platform_upload(config, args, host):
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
# A platform's own network path (nrf52 mcumgr) has no key handshake
_LOGGER.warning(
"The presented OTA key only applies to the %s OTA platform; "
"ignored for %s",
CONF_ESPHOME,
host,
)
return 0, host
port_type = get_port_type(host)
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
is_partition_table = getattr(args, "partition_table", False)
@@ -1281,6 +1300,8 @@ def upload_program(
network_devices = _resolve_network_devices(devices, config, args)
if chosen_platform == CONF_WEB_SERVER:
if ota_key:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
if is_partition_table or is_bootloader:
raise EsphomeError(
f"{option_string} is only supported with the esphome OTA platform; "
@@ -1294,6 +1315,17 @@ def upload_program(
return _upload_via_native_api(config, network_devices, args)
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
"""The network OTA platforms the config provides, one entry each, in
config order; the web_server final-validate hook merges duplicates."""
return {
ota_platform: None
for ota_item in config.get(CONF_OTA, [])
if (ota_platform := ota_item.get(CONF_PLATFORM))
in (CONF_ESPHOME, CONF_WEB_SERVER)
}
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
@@ -1306,16 +1338,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
# membership checks see one entry per platform even if the user has
# multiple ``ota:`` items of the same platform; the web_server OTA
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
available = _ota_upload_platforms(config)
if not available:
raise EsphomeError(
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
@@ -1355,7 +1378,12 @@ def _upload_via_native_api(
noise_psk = None
plaintext_fallback = False
allow_plaintext_upload = False
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
if ota_key := getattr(args, "ota_key", None):
# The user presents a key the device still runs instead of the
# configured one (a key change in progress); an explicit key never
# downgrades to plaintext
noise_psk = ota_key
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
allow_plaintext_upload = bool(
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
@@ -1737,7 +1765,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
OTA_KEY_PLATFORM_ERROR = (
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
"platform; to flash by serial instead, name the port with --device"
)
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
"""Take the key to present to the device for this upload, from the
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
not buried in build output."""
prompt = getattr(args, "prompt_ota_key", False)
env_key = getattr(args, "env_ota_key", None)
if not prompt and env_key is None:
return
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
return
# The HTTP path has no key handshake; decide before prompting or
# compiling. A serial target is a serial flash: the key is not even
# asked for, and --ota-platform means nothing to it. Every entry must
# be serial: a missing port is skipped and the next entry may be a host
devices = getattr(args, "device", None) or []
serial_target = bool(devices) and all(
device == "SERIAL"
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
for device in devices
)
chosen = (
_choose_ota_platform(config, getattr(args, "ota_platform", None))
if not serial_target and _ota_upload_platforms(config)
else None
)
if chosen == CONF_WEB_SERVER:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
from esphome.components.noise import validate_encryption_key
from esphome.config_validation import Invalid
if serial_target:
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
", ".join(devices),
)
return
key = read_secret_line("OTA encryption key: ") if prompt else env_key
try:
validate_encryption_key(key)
except Invalid as err:
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
args.ota_key = key
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
_read_ota_key(args, config)
# Get devices, resolving special identifiers like OTA
devices = choose_upload_log_host(
default=args.device,
@@ -1785,6 +1866,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
_read_ota_key(args, config)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -2205,6 +2287,25 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None:
)
def _error_format(value: str) -> ErrorFormat:
try:
return ErrorFormat(value.lower())
except ValueError:
choices = ", ".join(ErrorFormat)
raise argparse.ArgumentTypeError(
f"invalid choice: '{value}' (choose from {choices})"
) from None
ARGUMENT_HELP_PROMPT_OTA_KEY = (
"Ask for the OTA encryption key to present to the device instead of using "
"the one in the configuration, for a device that still runs a previous key. "
"The key is read without echo, or as one line from stdin when that is not "
"a terminal; it is never taken from the command line. A calling program "
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
)
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -2267,6 +2368,17 @@ def parse_args(argv):
),
)
options_parser.add_argument(
"--error-format",
type=_error_format,
metavar="{" + ",".join(f.value for f in ErrorFormat) + "}",
help=(
"How configuration errors are shown. 'yaml' shows them inside the "
"failing YAML; 'line' prints one 'file:line:column: error: message' "
"line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'."
),
)
parser = argparse.ArgumentParser(
description=f"ESPHome {const.__version__}", parents=[options_parser]
)
@@ -2362,6 +2474,11 @@ def parse_args(argv):
help="Upload as partition table (OTA).",
action="store_true",
)
parser_upload.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_upload.add_argument(
"--bootloader",
help="Upload as bootloader (OTA).",
@@ -2442,6 +2559,11 @@ def parse_args(argv):
"(HTTP Basic auth) when that is the only configured platform."
),
)
parser_run.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_clean = subparsers.add_parser(
"clean-mqtt",
@@ -2570,9 +2692,18 @@ def parse_args(argv):
args, unknown_args = parser.parse_known_args(arguments)
if unknown_args:
_LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args)
return args
else:
args = parser.parse_args(arguments)
return parser.parse_args(arguments)
# Read here rather than as the option default, so a bad value is reported
# against the environment variable instead of --error-format
if args.error_format is None:
env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value)
try:
args.error_format = _error_format(env_value)
except argparse.ArgumentTypeError as err:
parser.error(f"ESPHOME_ERROR_FORMAT: {err}")
return args
def _warn_if_source_tree_mismatch() -> None:
@@ -2645,6 +2776,7 @@ def run_esphome(argv):
args = parse_args(argv)
CORE.dashboard = args.dashboard
CORE.testing_mode = args.testing_mode
CORE.error_format = args.error_format
# Create address cache from command-line arguments
CORE.address_cache = AddressCache.from_cli_args(
@@ -2657,6 +2789,23 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
# Taken out of the environment before any command runs, so no build
# tool inherits a key a calling program handed over
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
if args.command in ("upload", "run"):
# One key is one device's; several configs run in child processes
# that would never see it
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
args.configuration
) > 1:
raise EsphomeError(
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
f"{len(args.configuration)} were given"
)
elif args.env_ota_key is not None:
_LOGGER.warning(
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
+274 -104
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import string
@@ -23,7 +23,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
from esphome.cpp_generator import (
FlashStringLiteral,
Expression,
LambdaExpression,
MockObj,
MockObjClass,
@@ -245,9 +245,7 @@ ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition)
def flash_string(config: ConfigType, value: str) -> str:
"""Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266."""
if CORE.is_esp8266:
return f"progmem_string({FlashStringLiteral(value)})"
return str(cg.safe_exp(value))
return str(cg.progmem_string(value))
def literal_with_length(config: ConfigType, value: str) -> str:
@@ -259,13 +257,24 @@ def literal_with_length(config: ConfigType, value: str) -> str:
return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}"
def string_ref_literal(config: ConfigType, value: str) -> str:
"""Renderer for a ``StringRef`` comparison: a flash literal on ESP8266, else ``StringRef(literal, length)``."""
if CORE.is_esp8266:
return str(cg.FlashStringLiteral(value))
return f"StringRef({literal_with_length(config, value)})"
def _names_parent(text: str) -> bool:
return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text))
@dataclass(frozen=True)
class ApplyCall:
"""One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``.
Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a
path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use
``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an
path into nested sections. ``target`` and a plain ``str`` ``type_`` (raw C++ type text) may
name the parent object as ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an
id bypasses it. The statement is skipped when none of its keys is set, always emitted when it
has no keys, and a partial set is a config error.
"""
@@ -277,13 +286,13 @@ class ApplyCall:
fields = [
f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None
]
if any(fields):
if any(f not in ("", "parent") for f in fields):
raise ValueError(
f"apply target {self.target!r}: only bare {{}} placeholders"
f"apply target {self.target!r}: only {{}} and {{parent}} placeholders"
)
if len(fields) != len(self.args):
if (count := fields.count("")) != len(self.args):
raise ValueError(
f"apply target {self.target!r} has {len(fields)} "
f"apply target {self.target!r} has {count} "
f"placeholder(s) for {len(self.args)} config key(s)"
)
if any(len(arg) not in (2, 3) for arg in self.args):
@@ -291,6 +300,12 @@ class ApplyCall:
f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])"
)
@property
def names_parent(self) -> bool:
return _names_parent(self.target) or any(
isinstance(arg[1], str) and _names_parent(arg[1]) for arg in self.args
)
@property
def members(self) -> list[tuple[Any, Any, Any]]:
"""Each arg as ``(conf_key, type_, const_fn or None)``."""
@@ -365,9 +380,16 @@ def _check_key_in_schema(
schema = schema.schema[markers[part]]
def parent_ref(var: MockObj) -> MockObj:
"""``var`` named from global scope, so a trigger argument cannot shadow it.
Also how a generated callback names its Automation.
"""
return MockObj(f"::{var}", "->")
async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str:
# Global-scope qualified so a trigger arg named like the id cannot shadow it.
return f"::{await cg.get_variable(config[id_key])}"
return str(parent_ref(await cg.get_variable(config[id_key])))
def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType:
@@ -399,7 +421,7 @@ async def _render_values(
members: list[tuple[Any, Any, Any]],
values: list[Any],
config: ConfigType,
parent: str,
parent: str | None,
lambda_args: TemplateArgsType,
compare: bool = False,
) -> list[str]:
@@ -443,6 +465,22 @@ def register_apply_action(
) -> None:
"""Register an action that only forwards config values to its parent, with no C++ class.
See ``apply_action_builder``.
"""
register_action(name, ApplyAction, schema, synchronous=True)(
apply_action_builder(name, schema, *fields, call=call, id_key=id_key)
)
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
"""The builder ``register_apply_action`` registers, for a caller that wraps it.
Generates one static function with the parent (read from ``id_key``) and constants baked
in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it.
A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names.
@@ -477,12 +515,12 @@ def register_apply_action(
statements: list[str] = []
for target, members in statements_spec:
values = _apply_values(config, members)
if members and all(value is None for value in values):
if not _apply_call_active(members, values):
continue
exprs = await _render_values(
name, target, members, values, config, parent, lambda_args
)
statements.append(f"{receiver}{target.format(*exprs)};")
statements.append(f"{receiver}{target.format(*exprs, parent=parent)};")
if call:
statements = [
f"auto apply_call = {parent}->{call}();",
@@ -493,7 +531,28 @@ def register_apply_action(
action_id, cg.void, template_arg, lambda_args, statements
)
register_action(name, ApplyAction, schema, synchronous=True)(builder)
return builder
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
"""An ``ApplyCall`` is emitted unless it has keys and none of them is set."""
return not members or any(value is not None for value in values)
async def _render_check(
name: str,
target: str,
members: list[tuple[Any, Any, Any]],
values: list[Any],
config: ConfigType,
parent: str | None,
lambda_args: TemplateArgsType,
) -> str:
"""Render a boolean check with ``values`` compared against config."""
exprs = await _render_values(
name, target, members, values, config, parent, lambda_args, compare=True
)
return target.format(*exprs, parent=parent)
def register_apply_condition(
@@ -521,22 +580,16 @@ def register_apply_condition(
) -> MockObj:
parent = await _apply_parent(config, id_key)
lambda_args = _apply_lambda_args(args)
exprs = await _render_values(
name,
call.target,
members,
_apply_values(config, members),
config,
parent,
lambda_args,
compare=True,
values = _apply_values(config, members)
check = await _render_check(
name, call.target, members, values, config, parent, lambda_args
)
return _apply_function(
condition_id,
cg.bool_,
template_arg,
lambda_args,
[f"return {parent}->{call.target.format(*exprs)};"],
[f"return {parent}->{check};"],
)
register_condition(name, ApplyCondition, schema)(builder)
@@ -672,54 +725,22 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
@register_condition("and", AndCondition, validate_condition_list)
async def and_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
async def or_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("all", AndCondition, validate_condition_list)
async def all_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
@register_condition("any", OrCondition, validate_condition_list)
async def any_condition_to_code(
@register_condition("xor", XorCondition, validate_condition_list)
async def condition_group_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
# A group of one is that condition for and, or and xor; skip the wrapper.
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions
)
@@ -734,19 +755,6 @@ async def not_condition_to_code(
return cg.new_Pvariable(condition_id, template_arg, condition)
@register_condition("xor", XorCondition, validate_condition_list)
async def xor_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
async def lambda_condition_to_code(
config: ConfigType,
@@ -848,11 +856,13 @@ async def if_action_to_code(
condition = await build_condition(config[cond_conf], template_arg, args)
var = cg.new_Pvariable(action_id, if_template_arg, condition)
if CONF_THEN in config:
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
if has_else:
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
cg.add(var.add_else(actions))
for action in await build_action_list(config[CONF_ELSE], template_arg, args):
cg.add(var.add_else_action(action))
cg.add(var.finish_else())
return var
@@ -875,8 +885,9 @@ async def while_action_to_code(
) -> MockObj:
condition = await build_condition(config[CONF_CONDITION], template_arg, args)
var = cg.new_Pvariable(action_id, template_arg, condition)
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
@@ -905,7 +916,9 @@ async def repeat_action_to_code(
cg.TemplateArguments(cg.uint32, *template_arg.args),
[(cg.uint32, "iteration"), *args],
)
cg.add(var.add_then(actions))
for action in actions:
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
@@ -1041,30 +1054,141 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool:
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
trigger args for later execution, making non-owning types like StringRef
unsafe.
Accepts an action list, a single action, or an automation. Only the keys of
action entries are looked up as action names; the options of an action are
only searched for nested action lists (``then:``, ``else:``, ...), so an
option that shares a name with an action, such as the ``delay:`` option of
``speaker_source.set_playlist_delay``, is not mistaken for that action.
"""
if isinstance(actions, list):
return any(has_non_synchronous_actions(item) for item in actions)
if isinstance(actions, dict):
for key in actions:
for key, options in actions.items():
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
return True
return any(
has_non_synchronous_actions(v)
for v in actions.values()
if isinstance(v, (list, dict))
)
if _options_have_non_synchronous_actions(options):
return True
return False
def _options_have_non_synchronous_actions(options: Any) -> bool:
"""Search an action's options for nested action lists, without treating option keys as actions."""
if isinstance(options, list):
return has_non_synchronous_actions(options)
if isinstance(options, dict):
return any(_options_have_non_synchronous_actions(v) for v in options.values())
return False
def validate_no_deferring_actions(
context: str, data: str = "the trigger data"
) -> Callable[[ConfigType], ConfigType]:
"""Return a validator that rejects deferring actions in a validated automation.
Use it for triggers whose arguments only live while the callback runs (views into a reused
buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later.
:param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``.
:param data: What is only valid while the automation runs, for the error message.
"""
def validator(value: ConfigType) -> ConfigType:
if has_non_synchronous_actions(value):
raise cv.Invalid(
f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in "
f"{context}: {data} is only valid while the automation runs. Copy what you need "
"into globals first, then defer in a separate script or automation."
)
return value
return validator
def validate_synchronous_automation(
context: str,
data: str = "the trigger data",
extra_schema=None,
extra_validators=None,
single: bool = False,
) -> cv.All:
"""``validate_automation()`` that also rejects deferring actions.
See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are
passed to ``validate_automation()``.
"""
return cv.All(
validate_automation(
extra_schema=extra_schema,
extra_validators=extra_validators,
single=single,
),
validate_no_deferring_actions(context, data),
)
async def _new_automation(
args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj
) -> MockObj:
"""Create the Automation for ``config`` with its actions."""
templ = cg.TemplateArguments(*(arg[0] for arg in args))
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args)
actions = await build_action_list(config[CONF_THEN], templ, args)
for action in actions:
cg.add(obj.add_action(action))
return obj
async def build_automation(
trigger: MockObj, args: TemplateArgsType, config: ConfigType
) -> MockObj:
arg_types = [arg[0] for arg in args]
templ = cg.TemplateArguments(*arg_types)
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger)
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
return obj
return await _new_automation(args, config, trigger)
async def build_trigger_callback(
args: TemplateArgsType,
config: ConfigType,
params: TemplateArgsType,
forward: Sequence[str | Expression] | None = None,
when: str | ApplyCall | None = None,
parent: MockObj | None = None,
) -> LambdaExpression:
"""Build the Automation for ``config`` and return a stateless callback that triggers it.
``params`` are the parent callback's parameters, ``forward`` the expressions passed to
``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``),
``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none
of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g.
``"{parent}->is_fully_open()"``.
"""
members: list[tuple[Any, Any, Any]] = []
if when is not None:
call = when if isinstance(when, ApplyCall) else ApplyCall(when)
members = call.members
if parent is None and call.names_parent:
raise ValueError(f"trigger filter {call.target!r} names {{parent}}")
obj = await _new_automation(args, config)
lambda_args = _apply_lambda_args(params)
statements: list[str] = []
if when is not None:
values = _apply_values(config, members)
if _apply_call_active(members, values):
check = await _render_check(
"trigger filter",
call.target,
members,
values,
config,
None if parent is None else str(parent_ref(parent)),
lambda_args,
)
statements.append(f"if (!({check}))\n return;")
if forward is None:
forward = [name for _, name in params]
statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});")
return LambdaExpression(
["\n".join(statements)], lambda_args, capture="", return_type=cg.void
)
async def build_callback_automation(
@@ -1073,6 +1197,9 @@ async def build_callback_automation(
args: TemplateArgsType,
config: ConfigType,
forwarder: MockObj | MockObjClass | None = None,
params: TemplateArgsType | None = None,
forward: Sequence[str | Expression] | None = None,
when: str | ApplyCall | None = None,
) -> None:
"""Build an Automation and register it as a callback on the parent.
@@ -1084,6 +1211,9 @@ async def build_callback_automation(
pointer-sized (single Automation* field) to fit inline in Callback::ctx_
and avoid heap allocation.
With ``params``, ``forward`` or ``when`` the callback is instead the stateless
lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them.
:param parent: The component object (e.g., button, sensor).
:param callback_method: Name of the callback method (e.g., "add_on_press_callback").
:param args: Automation template args as list of (type, name) tuples.
@@ -1092,22 +1222,56 @@ async def build_callback_automation(
TriggerForwarder<Ts...>. Pass any struct type whose aggregate init takes
a single Automation pointer (e.g., TriggerOnTrueForwarder).
"""
arg_types = [arg[0] for arg in args]
templ = cg.TemplateArguments(*arg_types)
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ)
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
if params is not None or forward is not None or when is not None:
if forwarder is not None:
raise ValueError(
"forwarder cannot be combined with params, forward or when"
)
callback = await build_trigger_callback(
args, config, args if params is None else params, forward, when, parent
)
cg.add(getattr(parent, callback_method)(callback))
return
obj = await _new_automation(args, config)
# Use template forwarder structs for deduplication. The compiler generates
# one operator() per forwarder type; different automation pointers are just
# data in the struct.
if forwarder is None:
forwarder = TriggerForwarder.template(templ)
forwarder = TriggerForwarder.template(*(arg[0] for arg in args))
# RawExpression for aggregate init — both forwarder and obj are codegen
# MockObjs (not user input), and there's no Expression type for positional
# aggregate initialization (StructInitializer uses named fields).
cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
async def build_parent_callback_automation(
parent: MockObj, callback_method: str, arg: tuple[Any, str], config: ConfigType
) -> None:
"""Register an Automation that receives ``parent`` from a callback that carries nothing.
``arg`` is the automation's ``(type, name)``, e.g. ``(Fan.operator("ptr"), "x")``.
"""
await build_callback_automation(
parent, callback_method, [arg], config, params=[], forward=[parent_ref(parent)]
)
async def build_trigger_automations(
parent: MockObj | None,
config: ConfigType,
entries: tuple[tuple[str, TemplateArgsType], ...],
) -> None:
"""Instantiate each entry's Trigger class, with ``parent`` when given, and build its automations.
``entries`` are ``(conf_key, args)`` pairs; the class comes from the entry's ``CONF_TRIGGER_ID``.
"""
ctor_args = () if parent is None else (parent,)
for conf_key, args in entries:
for conf in config.get(conf_key, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], *ctor_args)
await build_automation(trigger, args, conf)
@dataclass(frozen=True, slots=True)
class CallbackAutomation:
"""A single callback automation entry for build_callback_automations."""
@@ -1116,6 +1280,9 @@ class CallbackAutomation:
callback_method: str
args: TemplateArgsType = field(default_factory=list)
forwarder: MockObj | MockObjClass | None = None
params: TemplateArgsType | None = None
forward: Sequence[str | Expression] | None = None
when: str | ApplyCall | None = None
async def build_callback_automations(
@@ -1137,4 +1304,7 @@ async def build_callback_automations(
entry.args,
conf,
forwarder=entry.forwarder,
params=entry.params,
forward=entry.forward,
when=entry.when,
)
+1 -1
View File
@@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
"rule link",
+51 -15
View File
@@ -237,6 +237,35 @@ def idf_macro_matches(idf_path: Path) -> bool:
return live == _EXPECTED_MACRO
def _cmake_args_for_write(minimal: bool) -> dict[str, str]:
"""The cg.add_cmake_arg() values for one project write.
The discovery (minimal) write lets every built-in component register so
the list it finds, cached per target, serves any exclusion set; the full
write drops the excluded names from that list and passes all of them to
IDF. Names outside the framework's components directory (managed
components) stay excluded on the discovery write too, since the list
only holds built-in ones. A checkout supplied through IDF_PATH caches
nothing, so its discovery keeps every exclusion.
"""
from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path
args = dict(CORE.cmake_args)
if (
not minimal
or not _esphome_manages_idf()
or not (excluded := args.get("EXCLUDE_COMPONENTS"))
):
return args
root = _get_idf_path() / "components"
outside = [name for name in excluded.split(";") if not (root / name).is_dir()]
if outside:
args["EXCLUDE_COMPONENTS"] = ";".join(outside)
else:
del args["EXCLUDE_COMPONENTS"]
return args
def get_project_cmakelists(
minimal: bool = False, builtin_components: list[str] | None = None
) -> str:
@@ -300,11 +329,10 @@ def get_project_cmakelists(
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
# (discovery) writes too so excluded components never register.
# set when project.cmake seeds the component list.
cmake_args = "\n".join(
f"set({name} {_cmake_quote(value)})"
for name, value in sorted(CORE.cmake_args.items())
for name, value in sorted(_cmake_args_for_write(minimal).items())
)
# Per-project list exposed as a CMake variable so converted PIO libs
@@ -327,10 +355,10 @@ def get_project_cmakelists(
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
# Excluded components are dropped here as well: a stale
# project_description.json from a build without exclusions may still
# list them, and requiring an excluded component pulls it back into
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
# Excluded components are dropped here as well: the discovery list
# (cached per target) still holds them, and requiring an excluded
# component pulls it back into the build (IDF requirement expansion
# overrides EXCLUDE_COMPONENTS).
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
# two can never disagree within one generated file.
builtin_components_property = (
@@ -397,15 +425,24 @@ project({CORE.name})
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
# target depends on the map and can then be built in the same run as all.
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
# IDF's size target depends on the map, so ninja has to know the link
# writes it for size to build in the same run as all. IDF declares the
# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja
# rejects two rules for one output, so declare it here only when IDF has
# not. add_custom_command(OUTPUT) marks its outputs GENERATED.
get_source_file_property(esphome_map_declared
${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED)
if(esphome_map_declared)
set(esphome_map_byproducts)
else()
set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map)
endif()
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
${{esphome_map_byproducts}}
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
@@ -554,10 +591,9 @@ def write_project(
)
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
# config gains mqtt) requires a fresh discovery pass before the
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
# reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS
# property drops excluded names, so re-including one (e.g. a config
# gains mqtt) needs a reconfigure to rebuild it from the discovery list.
write_file_if_changed(
CORE.relative_build_path("exclude_components.esphomeinternal"),
";".join(get_excluded_builtin_components()),
+1 -1
View File
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
+14 -7
View File
@@ -74,33 +74,40 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
+3
View File
@@ -34,14 +34,17 @@ from esphome.cpp_generator import ( # noqa: F401
extern_progmem_array,
get_variable,
get_variable_with_full_id,
is_static_pointer,
is_template,
new_Pvariable,
new_variable,
process_lambda,
progmem_array,
progmem_string,
safe_exp,
set_cpp_standard,
shared_progmem_array,
shared_progmem_string,
statement,
static_const_array,
static_function,
+4 -4
View File
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
bool output_raw_{false};
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
InternalGPIOPin *pin_;
SamplingMode sampling_mode_{SamplingMode::AVG};
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
#ifdef USE_ESP32
float sample_autorange_();
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
bool autorange_{false};
adc_oneshot_unit_handle_t adc_handle_{nullptr};
adc_cali_handle_t calibration_handle_{nullptr};
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
adc_channel_t channel_{};
adc_unit_t adc_unit_{};
struct SetupFlags {
+24 -8
View File
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
CONF_SAMPLES = "samples"
CONF_SAMPLING_MODE = "sampling_mode"
# Keep in sync with the initializers in adc_sensor.h
DEFAULT_ATTENUATION = "0db"
DEFAULT_SAMPLING_MODE = "avg"
DEFAULT_SAMPLES = 1
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
):
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
if (
config.get(CONF_ATTENUATION, None) == "auto"
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
):
raise cv.Invalid(
"Automatic attenuation cannot be used when multisampling is set"
)
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): validate_adc_pin,
cv.Optional(CONF_RAW, default=False): cv.boolean,
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
cv.only_on_esp32, _attenuation
),
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
min=1, max=255
),
cv.Optional(
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
): _sampling_mode,
}
)
.extend(cv.polling_component_schema("60s")),
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_output_raw(config[CONF_RAW]))
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
# Skip the setters when the config matches the C++ initializers.
if config[CONF_RAW]:
cg.add(var.set_output_raw(True))
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
cg.add(var.set_sample_count(samples))
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
cg.add(var.set_sampling_mode(sampling_mode))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))
else:
elif attenuation != DEFAULT_ATTENUATION:
cg.add(var.set_attenuation(attenuation))
variant = get_esp32_variant()
+8 -34
View File
@@ -54,7 +54,7 @@ from esphome.const import (
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash
from esphome.helpers import fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
@@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All(
)
def _add_action_strings(
index: int, strings: list[str | None], interned: dict[str, MockObj]
) -> MockObj:
"""Emit the PROGMEM string table for one action.
Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical
strings are shared between actions through `interned`.
"""
entries: list[MockObj] = []
for string in strings:
if string is None:
entries.append(cg.nullptr)
continue
if (var := interned.get(string)) is None:
var = interned[string] = cg.progmem_array(
ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char),
string,
)
entries.append(var)
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
"""Emit the PROGMEM string table for one action."""
return cg.progmem_array(
ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr),
entries,
[cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings],
)
@@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
interned_strings: dict[str, MockObj] = {}
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None:
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings, interned_strings)
table = _add_action_strings(index, strings)
if CORE.is_esp8266:
scratch_size = max(scratch_size, _action_strings_size(strings))
# Template args: supports_response mode, then user service arg types
@@ -682,7 +664,7 @@ async def to_code(config: ConfigType) -> None:
unregister_id,
var,
)
cg.add(auto.add_actions([unregister_action]))
cg.add(auto.add_action(unregister_action))
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
if CORE.is_esp8266 and has_user_actions:
@@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
)
def _field_string(value: str) -> Expression:
# ESP8266 can only keep a string in flash as its own PROGMEM array
literal = cg.RawExpression(cpp_string_escape(value))
if CORE.is_esp8266:
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
return literal
async def _new_service_call_action(
server_id: ID,
action_id: ID,
@@ -858,12 +832,12 @@ async def _new_service_call_action(
)
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
key_exp = cg.nullptr if key is None else _field_string(key)
key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key)
if cg.is_template(value):
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
lam = await cg.process_lambda(value, args, return_type=output_type)
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr)
entries = [await field(None, service, cg.std_string)]
for group in groups:
+19 -6
View File
@@ -444,7 +444,7 @@ message DeviceWizardRequest {
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...",
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
@@ -468,6 +468,14 @@ message DeviceWizardRequest {
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
@@ -483,8 +491,13 @@ message DeviceWizardResponse {
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM,
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
@@ -496,7 +509,7 @@ message WizardInputSetRequest {
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 255];
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
@@ -760,7 +773,7 @@ message ListEntitiesLightResponse {
bool legacy_supports_color_temperature = 8 [deprecated=true];
float min_mireds = 9;
float max_mireds = 10;
repeated string effects = 11 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string effects = 11 [(container_pointer_no_template) = "light::LightEffectNames"];
bool disabled_by_default = 13;
string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 15;
@@ -2557,7 +2570,7 @@ message ListEntitiesEventResponse {
EntityCategory entity_category = 7;
string device_class = 8 [(max_data_length) = 47];
repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
}
message EventResponse {
+1 -10
View File
@@ -644,16 +644,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c
msg.min_mireds = traits.get_min_mireds();
msg.max_mireds = traits.get_max_mireds();
}
FixedVector<const char *> effects_list;
if (light->supports_effects()) {
auto &light_effects = light->get_effects();
effects_list.init(light_effects.size() + 1);
effects_list.push_back("None");
for (auto *effect : light_effects) {
// c_str() is safe as effect names are null-terminated strings from codegen
effects_list.push_back(effect->get_name().c_str());
}
}
light::LightEffectNames effects_list(light->get_effects());
msg.effects = &effects_list;
return fill_and_encode_entity_info(light, msg, conn, remaining_size);
}
+4 -4
View File
@@ -688,8 +688,8 @@ uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__rest
if (uint32_t raw = float_to_raw(msg.max_mireds); raw != 0) [[likely]] {
pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw);
}
for (const char *it : *msg.effects) {
pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it));
for (const auto &it : *msg.effects) {
pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it);
}
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default);
#ifdef USE_ENTITY_ICON
@@ -713,8 +713,8 @@ uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) {
size += ProtoSize::calc_float(1, msg.min_mireds);
size += ProtoSize::calc_float(1, msg.max_mireds);
if (!msg.effects->empty()) {
for (const char *it : *msg.effects) {
size += ProtoSize::calc_length_force(1, strlen(it));
for (const auto &it : *msg.effects) {
size += ProtoSize::calc_length_force(1, it.size());
}
}
size += ProtoSize::calc_bool(1, msg.disabled_by_default);
+2 -2
View File
@@ -1059,7 +1059,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage {
const light::ColorModeMask *supported_color_modes{};
float min_mireds{0.0f};
float max_mireds{0.0f};
const FixedVector<const char *> *effects{};
const light::LightEffectNames *effects{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage {
const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); }
#endif
StringRef device_class{nullptr, 0}; // null until set, encode only
const FixedVector<const char *> *event_types{};
const ConstVector<const char *, true> *event_types{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -15,6 +15,7 @@
#endif
#ifdef USE_LIGHT
#include "esphome/components/light/light_effect_names.h"
#include "esphome/components/light/light_traits.h"
#endif
+2
View File
@@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() {
return;
}
}
// Generating the key pair cannot be split across loop passes
UnavoidableBlockingScope scope;
noise::prepare_spare_ephemeral();
}
#endif
+49 -7
View File
@@ -14,26 +14,68 @@ namespace esphome::api {
ESPHOME_LOG_TAG(TAG, "api.wizard");
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
+33 -2
View File
@@ -6,6 +6,9 @@
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
@@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
@@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one.
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
+48 -7
View File
@@ -20,6 +20,8 @@ from esphome.const import (
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
@@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
@@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
@@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
@@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
@@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
@@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
@@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...",
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
@@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
@@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None:
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}"))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
+1 -1
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.const import CONF_AQI # noqa: F401
CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"]
DOMAIN = "aqi"
@@ -6,7 +7,6 @@ DOMAIN = "aqi"
aqi_ns = cg.esphome_ns.namespace("aqi")
AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
+1 -1
View File
@@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
@@ -3,6 +3,7 @@ from pathlib import Path
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
from esphome.components.const import CONF_VERIFY_SSL
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
import esphome.final_validate as fv
@@ -16,7 +17,6 @@ AUTO_LOAD = ["audio"]
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
CONF_HTTP_REQUEST = "http_request"
CONF_VERIFY_SSL = "verify_ssl"
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
+1 -4
View File
@@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
uint8_t checksum = 0;
// Calculate checksum and compare with signal value.
for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++)
checksum += remote_state[i];
const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1);
if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
+2 -1
View File
@@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config):
if filters_config := config.get(CONF_FILTERS):
cg.add_define("USE_BINARY_SENSOR_FILTER")
filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config)
cg.add(var.add_filters(filters))
for filter_ in filters:
cg.add(var.add_filter(filter_))
CORE.add_job(_build_binary_sensor_automations, var, config)
+14 -6
View File
@@ -51,12 +51,8 @@ void BL0939::loop() {
}
bool BL0939::validate_checksum(const DataPacket *data) {
uint8_t checksum = BL0939_READ_COMMAND;
// Whole package but checksum
for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) {
checksum += data->raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
@@ -120,13 +116,24 @@ void BL0939::received_package_(const DataPacket *data) const {
energy_sensor_sum_->publish_state(total_energy_consumption);
}
if (this->internal_temperature_sensor_ != nullptr) {
const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF;
// Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45.
const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f;
this->internal_temperature_sensor_->publish_state(temperature);
}
ESP_LOGV(TAG,
"BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh",
v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption);
}
void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity)
ESP_LOGCONFIG(TAG, "BL0939:");
ESP_LOGCONFIG(TAG,
"BL0939:\n"
" Mode: %s",
this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer")
: LOG_STR_LITERAL("direct"));
LOG_SENSOR("", "Voltage", this->voltage_sensor_);
LOG_SENSOR("", "Current 1", this->current_sensor_1_);
LOG_SENSOR("", "Current 2", this->current_sensor_2_);
@@ -135,6 +142,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "Energy 1", this->energy_sensor_1_);
LOG_SENSOR("", "Energy 2", this->energy_sensor_2_);
LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_);
LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_);
}
uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; }
+35 -4
View File
@@ -8,13 +8,32 @@ namespace esphome::bl0939 {
// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf
// (unfortunately chinese, but the formulas can be easily understood)
// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode.
// Direct mode.
// Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm)
// and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm)
// as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V)
static const float BL0939_IREF = 324004 * 1 / 1.218;
static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_IREF = 324004 * 1 / 1.218;
static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
// Current transformer mode.
// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V).
// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp.
// That is the same as a 0.3mOhm shunt in the direct mode formula.
// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on
// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2).
static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218;
static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000);
static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000);
static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000);
enum class BL0939Mode : uint8_t {
BL0939_MODE_DIRECT = 0,
BL0939_MODE_CURRENT_TRANSFORMER = 1,
};
struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align)
uint8_t l;
@@ -66,6 +85,16 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; }
void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; }
void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; }
void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; }
// Inline so a constant mode from codegen folds down to plain stores.
void set_mode(BL0939Mode mode) {
this->mode_ = mode;
const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER;
this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF;
this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF;
this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF;
this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF;
}
void loop() override;
@@ -84,6 +113,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
sensor::Sensor *energy_sensor_1_{nullptr};
sensor::Sensor *energy_sensor_2_{nullptr};
sensor::Sensor *energy_sensor_sum_{nullptr};
sensor::Sensor *internal_temperature_sensor_{nullptr};
// Divide by this to turn into Watt
float power_reference_ = BL0939_PREF;
@@ -93,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
float current_reference_ = BL0939_IREF;
// Divide by this to turn into kWh
float energy_reference_ = BL0939_EREF;
BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT};
static uint32_t to_uint32_t(ube24_t input);
+27
View File
@@ -3,14 +3,19 @@ from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INTERNAL_TEMPERATURE,
CONF_MODE,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_KILOWATT_HOURS,
UNIT_VOLT,
UNIT_WATT,
@@ -29,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total"
bl0939_ns = cg.esphome_ns.namespace("bl0939")
BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice)
BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True)
MODE_OPTIONS = {
"direct": BL0939Mode.BL0939_MODE_DIRECT,
"current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER,
}
# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped
# when the config already equals it.
DEFAULT_MODE = "direct"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BL0939),
cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum(
MODE_OPTIONS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
@@ -82,6 +98,13 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -94,6 +117,9 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.set_mode(mode))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT_1, var.set_current_sensor_1)
@@ -103,3 +129,4 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_ENERGY_1, var.set_energy_sensor_1)
await sensors(CONF_ENERGY_2, var.set_energy_sensor_2)
await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
+2 -6
View File
@@ -44,13 +44,9 @@ void BL0940::loop() {
}
bool BL0940::validate_checksum_(DataPacket *data) {
uint8_t checksum = this->read_command_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+3 -6
View File
@@ -70,13 +70,10 @@ void BL0942::loop() {
}
bool BL0942::validate_checksum_(DataPacket *data) {
uint8_t checksum = BL0942_READ_COMMAND | this->address_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^
0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+2 -1
View File
@@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.uart import (
UARTComponent,
debug_to_code,
mark_unclocked,
maybe_empty_debug,
uart_ns,
)
@@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLENUS),
cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked),
cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of(
*[CONF_LOGS, CONF_UART], lower=True
),
+31 -6
View File
@@ -1,14 +1,17 @@
import logging
import re
from typing import Any
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID
from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@mvturnho", "@danielschramm"]
DOMAIN = "canbus"
IS_PLATFORM_COMPONENT = True
@@ -20,6 +23,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
CONF_CANBUS_ID = "canbus_id"
CONF_BIT_RATE = "bit_rate"
CONF_ON_FRAME = "on_frame"
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
def validate_id(config: ConfigType) -> ConfigType:
@@ -87,8 +93,13 @@ CANBUS_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(CanbusComponent),
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
CAN_SPEEDS, upper=True
),
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
cv.Optional(
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
): cv.positive_not_null_int,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
@@ -109,9 +120,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
cg.add(var.set_can_id(config[CONF_CAN_ID]))
if config[CONF_USE_EXTENDED_ID]:
cg.add(var.set_use_extended_id(True))
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
cg.add(var.set_max_frames_per_loop(max_frames))
for conf in config.get(CONF_ON_FRAME, []):
can_id = conf[CONF_CAN_ID]
@@ -138,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
)
async def register_canbus(var: MockObj, config: ConfigType) -> None:
async def register_canbus(
var: MockObj, config: ConfigType, *, reports_status: bool | None = None
) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.new_Pvariable(config[CONF_ID], var)
if reports_status is None:
_LOGGER.warning(
"CAN bus platform '%s' does not report bus state, so bus-off recovery and "
"error-state logging are disabled. Platform authors should either override "
"get_status() and get_events() and call register_canbus(..., reports_status=True) "
"or pass reports_status=False if the hardware cannot report it",
config[CONF_PLATFORM],
)
await setup_canbus_core_(var, config)
+73 -7
View File
@@ -1,5 +1,6 @@
#include "canbus.h"
#include <algorithm>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::canbus {
@@ -65,17 +66,25 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
};
void Canbus::loop() {
uint32_t now = App.get_loop_component_start_time();
if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) {
this->log_events_(this->get_events(), now);
this->last_event_check_time_ = now;
}
struct CanFrame can_message;
// read all messages until queue is empty
int message_counter = 0;
while (this->read_message(&can_message) == canbus::ERROR_OK) {
message_counter++;
uint32_t message_counter = 0;
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
if (this->read_message(&can_message) != canbus::ERROR_OK) {
break;
}
if (can_message.use_extended_id) {
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
} else {
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
can_message.can_data_length_code);
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
}
std::vector<uint8_t> data;
@@ -99,6 +108,63 @@ void Canbus::loop() {
}
}
}
if (message_counter == this->max_frames_per_loop_) {
ESP_LOGW(TAG,
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
this->max_frames_per_loop_);
}
}
void Canbus::log_events_(CanEventFlags events, uint32_t now) {
// Bus-off gets its own handling because automatic recovery can make it flap
if (events & CanEventFlags::CAN_EVENT_BUS_OFF) {
this->last_bus_off_time_ = now;
if (!this->bus_off_) {
this->bus_off_ = true;
this->status_set_warning(LOG_STR("Bus off"));
}
} else if (this->bus_off_) {
if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) {
// No bus-off event for the hold-off time, so ask whether it is really over
if (this->get_status().bus_off) {
this->last_bus_off_time_ = now;
} else {
this->bus_off_ = false;
this->status_clear_warning();
// Events gathered while bus-off say nothing about the recovered bus
this->events_to_log_ = 0;
}
}
} else {
this->events_to_log_ |= events;
if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) {
if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) {
bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE;
if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) {
// Both edges within one throttle window, so the counters decide the current state
auto status = this->get_status();
error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD ||
status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD;
}
ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active"));
}
if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) {
ESP_LOGD(TAG, "receive buffer overrun");
}
this->events_to_log_ = 0;
this->last_event_log_time_ = now;
}
}
#ifdef ESPHOME_LOG_HAS_VERBOSE
if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) {
auto status = this->get_status();
ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter,
status.rx_error_counter);
this->last_state_log_time_ = now;
}
#endif
}
} // namespace esphome::canbus
+48 -2
View File
@@ -48,6 +48,8 @@ template<typename... Ts> class CanbusSendAction;
/* CAN payload length definitions according to ISO 11898-1 */
static const uint8_t CAN_MAX_DATA_LENGTH = 8;
static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128;
/*
Can Frame describes a normative CAN Frame
The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used
@@ -61,6 +63,26 @@ struct CanFrame {
uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8)));
};
enum CanEventFlags : uint8_t {
CAN_EVENT_PASSIVE = 1 << 0,
CAN_EVENT_ACTIVE = 1 << 1,
CAN_EVENT_BUS_OFF = 1 << 2,
CAN_EVENT_RX_QUEUE_FULL = 1 << 3,
};
static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX;
struct CanStatus {
bool bus_off{false};
uint8_t rx_error_counter{0};
uint8_t tx_error_counter{0};
uint32_t tx_failed_count{CAN_STATUS_UNSET};
uint32_t rx_missed_count{CAN_STATUS_UNSET};
uint32_t rx_overrun_count{CAN_STATUS_UNSET};
uint32_t arb_lost_count{CAN_STATUS_UNSET};
uint32_t bus_error_count{CAN_STATUS_UNSET};
};
class Canbus : public Component {
public:
Canbus(){};
@@ -82,6 +104,8 @@ class Canbus : public Component {
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
virtual CanStatus get_status() { return {}; }
void add_trigger(CanbusTrigger *trigger);
/**
@@ -100,14 +124,36 @@ class Canbus : public Component {
template<typename... Ts> friend class CanbusSendAction;
std::vector<CanbusTrigger *> triggers_{};
uint32_t can_id_;
bool use_extended_id_;
CanSpeed bit_rate_;
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
bool use_extended_id_{false};
uint8_t events_to_log_{0};
bool bus_off_{false};
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
// interval in which to check for new can bus events (bus-off, passive, active)
static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100;
uint32_t last_event_check_time_{0};
// time to wait after logging an event before logging the next event(s)
static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000;
uint32_t last_event_log_time_{0};
// time span without bus-off events before the bus is declared recovered
static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000;
uint32_t last_bus_off_time_{0};
#ifdef ESPHOME_LOG_HAS_VERBOSE
static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000;
uint32_t last_state_log_time_{0};
#endif
void log_events_(CanEventFlags events, uint32_t now);
virtual bool setup_internal() = 0;
virtual Error send_message(struct CanFrame *frame) = 0;
virtual Error read_message(struct CanFrame *frame) = 0;
virtual CanEventFlags get_events() { return CanEventFlags{}; }
};
template<typename... Ts> class CanbusSendAction final : public Action<Ts...>, public Parented<Canbus> {
+2 -26
View File
@@ -4,7 +4,6 @@ import esphome.codegen as cg
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.components.wifi import DOMAIN as WIFI_DOMAIN
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
@@ -16,7 +15,6 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -78,17 +76,7 @@ def _final_validate(config: ConfigType) -> None:
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
# Register socket needs for DNS server and additional HTTP connections
# - 1 UDP socket for DNS server
# - 3 TCP sockets for captive portal detection probes + configuration requests
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
# Need headroom for actual user configuration requests.
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
# The listening socket is registered by web_server_base (shared HTTP server).
from esphome.components import socket
socket.consume_sockets(3, "captive_portal")(config)
socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config)
web_server_base.consume_captive_dns_sockets(config, DOMAIN)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
# Only compile the ESP-IDF DNS server when using ESP-IDF framework
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"dns_server_esp32_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
}
)
web_server_base.add_captive_dns_library()
@@ -6,9 +6,6 @@
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/scan_list.h"
#include "esphome/components/wifi/wifi_component.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#include "captive_index.h"
namespace esphome::captive_portal {
@@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
void CaptivePortal::setup() {
// Disable loop by default - will be enabled when captive portal starts
this->disable_loop();
#ifdef USE_PROVISIONING
// The captive portal is a provisioning surface: once the provisioning window
// has closed, stop serving it. WiFi's own closed-callback shuts down the
// access point the portal runs on, and the gated fallback in WiFiComponent's
// loop() ensures neither is started again afterwards.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
if (this->active_) {
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
this->end();
}
});
}
#endif
}
void CaptivePortal::start() {
this->base_->init();
@@ -102,17 +85,7 @@ void CaptivePortal::start() {
this->base_->add_handler_without_auth(this);
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
#if defined(USE_ESP32)
// Create DNS server instance for ESP-IDF
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
this->initialized_ = true;
this->active_ = true;
@@ -1,16 +1,11 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_CAPTIVE_PORTAL
#include <memory>
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/web_server_base/captive_dns.h"
namespace esphome::captive_portal {
@@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
CaptivePortal(web_server_base::WebServerBase *base);
void setup() override;
void dump_config() override;
void loop() override {
#if defined(USE_ESP32)
if (this->dns_server_ != nullptr) {
this->dns_server_->process_next_request();
}
#elif defined(USE_ARDUINO)
if (this->dns_server_ != nullptr) {
this->dns_server_->processNextRequest();
}
#endif
}
void loop() override { this->dns_.loop(); }
float get_setup_priority() const override;
void start();
bool is_active() const { return this->active_; }
@@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
this->dns_.stop();
}
bool canHandle(AsyncWebServerRequest *request) const override {
@@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
web_server_base::WebServerBase *base_;
bool initialized_{false};
bool active_{false};
#if defined(USE_ARDUINO) || defined(USE_ESP32)
std::unique_ptr<DNSServer> dns_server_{nullptr};
#endif
web_server_base::CaptiveDNS dns_;
};
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -36,19 +36,6 @@ CONFIG_SCHEMA = cv.All(
)
def _subtree_references_uart(node: object, uart_id: str) -> bool:
"""Return True if any dict in the subtree has a uart_id entry naming this bus."""
if isinstance(node, dict):
return any(
(key == CONF_UART_ID and str(value) == uart_id)
or _subtree_references_uart(value, uart_id)
for key, value in node.items()
)
if isinstance(node, list):
return any(_subtree_references_uart(item, uart_id) for item in node)
return False
def _reject_debug(uart_conf: ConfigType) -> ConfigType:
# The worker tasks use the IDF driver directly, so the uart debugger never sees
# bridge traffic and its dummy_receiver would drain RX bytes on the main loop.
@@ -86,7 +73,7 @@ def _final_validate(config: ConfigType) -> ConfigType:
for domain, domain_conf in full_config.items():
if domain == BRIDGE_DOMAIN:
continue
if _subtree_references_uart(domain_conf, owned_id):
if uart.subtree_references_uart(domain_conf, owned_id):
raise cv.Invalid(
f"The {label} '{owned_id}' is also used by '{domain}'; a bridge "
f"requires exclusive use of its {label}.",
+13 -30
View File
@@ -46,7 +46,6 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_TRIGGER_ID,
CONF_VISUAL,
CONF_WEB_SERVER,
)
@@ -164,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType:
)
# Actions
StateTrigger = climate_ns.class_(
"StateTrigger", automation.Trigger.template(Climate.operator("ref"))
)
ControlTrigger = climate_ns.class_(
"ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref"))
)
_CLIMATE_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -250,16 +241,8 @@ _CLIMATE_SCHEMA = (
cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.publish_topic
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger),
}
),
cv.Optional(CONF_ON_STATE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
}
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(),
cv.Optional(CONF_ON_STATE): automation.validate_automation(),
}
)
)
@@ -288,6 +271,16 @@ def climate_schema(
return _CLIMATE_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")]
),
automation.CallbackAutomation(
CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")]
),
)
@setup_entity("climate")
async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
visual = config.get(CONF_VISUAL, {})
@@ -443,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
)
)
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(Climate.operator("ref"), "x")], conf
)
for conf in config.get(CONF_ON_CONTROL, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(ClimateCall.operator("ref"), "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "climate.h"
namespace esphome::climate {
class ControlTrigger final : public Trigger<ClimateCall &> {
public:
ControlTrigger(Climate *climate) {
climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); });
}
};
class StateTrigger final : public Trigger<Climate &> {
public:
StateTrigger(Climate *climate) {
climate->add_on_state_callback([this](Climate &x) { this->trigger(x); });
}
};
} // namespace esphome::climate
+60 -21
View File
@@ -6,10 +6,16 @@
namespace esphome::cm1106 {
ESPHOME_LOG_TAG(TAG, "cm1106");
static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED};
static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00};
static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6};
static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED};
static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00};
static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6};
// The factory default ABC calibration cycle differs between CM1106 models, so
// every ABC write applies the configured cycle and baseline explicitly.
static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00};
static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9};
static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0;
static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2;
uint8_t cm1106_checksum(const uint8_t *response, size_t len) {
uint8_t crc = 0;
for (size_t i = 0; i < len - 1; i++) {
@@ -25,34 +31,46 @@ void CM1106Component::setup() {
this->mark_failed();
return;
}
if (this->abc_boot_logic_ != CM1106_ABC_NONE) {
this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED);
}
}
void CM1106Component::update() {
uint8_t response[8] = {0};
if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) {
ESP_LOGW(TAG, "Reading data from CM1106 failed!");
ESP_LOGW(TAG, "Reading data failed!");
this->status_set_warning();
return;
}
if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) {
ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2],
response[3]);
ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]);
this->status_set_warning();
return;
}
uint8_t checksum = cm1106_checksum(response, sizeof(response));
if (response[7] != checksum) {
ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum);
ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum);
this->status_set_warning();
return;
}
const uint16_t ppm = response[3] << 8 | response[4];
const uint8_t status = response[5];
if (status & 0x1) {
ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm);
this->status_set_warning();
return;
}
if (status) {
ESP_LOGW(TAG, "Status: %02X", status);
}
this->status_clear_warning();
uint16_t ppm = response[3] << 8 | response[4];
ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]);
ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]);
if (this->co2_sensor_ != nullptr)
this->co2_sensor_->publish_state(ppm);
}
@@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) {
memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd));
cmd[3] = ppm >> 8;
cmd[4] = ppm & 0xFF;
if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) {
ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm);
}
}
void CM1106Component::abc_set_(bool enabled) {
ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis"));
uint8_t cmd[10];
memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd));
cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE;
cmd[5] = this->abc_cycle_;
cmd[6] = this->abc_baseline_ >> 8;
cmd[7] = this->abc_baseline_ & 0xFF;
if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) {
ESP_LOGD(TAG, "Successfully set ABC status");
}
}
bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) {
uint8_t response[4] = {0};
if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) {
ESP_LOGW(TAG, "Reading data from CM1106 failed!");
if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) {
ESP_LOGW(TAG, "Command %02X failed", command[2]);
this->status_set_warning();
return;
return false;
}
// check if correct response received
if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) {
ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2],
response[3]);
if (memcmp(response, ack, sizeof(response)) != 0) {
ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]);
this->status_set_warning();
return;
return false;
}
this->status_clear_warning();
ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm);
return true;
}
bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response,
@@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma
void CM1106Component::dump_config() {
ESP_LOGCONFIG(TAG, "CM1106:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
if (this->abc_boot_logic_ != CM1106_ABC_NONE) {
ESP_LOGCONFIG(TAG,
" Automatic baseline calibration on boot: %s\n"
" ABC calibration cycle: %u days\n"
" ABC baseline: %uppm",
ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_);
}
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
+23 -2
View File
@@ -6,6 +6,14 @@
namespace esphome::cm1106 {
// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the
// sensor keeps its stored setting.
enum CM1106ABCLogic : uint8_t {
CM1106_ABC_NONE = 0,
CM1106_ABC_ENABLED,
CM1106_ABC_DISABLED,
};
class CM1106Component final : public PollingComponent, public uart::UARTDevice {
public:
void setup() override;
@@ -13,13 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice {
void dump_config() override;
void calibrate_zero(uint16_t ppm);
void abc_enable() { this->abc_set_(true); }
void abc_disable() { this->abc_set_(false); }
void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; }
void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) {
this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED;
this->abc_cycle_ = cycle_days;
this->abc_baseline_ = baseline_ppm;
}
protected:
sensor::Sensor *co2_sensor_{nullptr};
bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len);
/// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status.
bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack);
void abc_set_(bool enabled);
sensor::Sensor *co2_sensor_{nullptr};
CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE};
uint8_t abc_cycle_{15};
uint16_t abc_baseline_{400};
};
} // namespace esphome::cm1106
+29
View File
@@ -4,9 +4,12 @@ from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION
import esphome.config_validation as cv
from esphome.const import (
CONF_BASELINE,
CONF_CO2,
CONF_CYCLE,
CONF_ID,
DEVICE_CLASS_CARBON_DIOXIDE,
ICON_MOLECULE_CO2,
@@ -18,11 +21,22 @@ from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
CODEOWNERS = ["@andrewjswan"]
# true enables ABC with these defaults, false disables it, a mapping enables it with custom values
_ABC_SETTINGS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90),
cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000),
}
)
cm1106_ns = cg.esphome_ns.namespace("cm1106")
CM1106Component = cm1106_ns.class_(
"CM1106Component", cg.PollingComponent, uart.UARTDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
@@ -34,6 +48,9 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any(
cv.boolean, _ABC_SETTINGS_SCHEMA
),
},
)
.extend(cv.polling_component_schema("60s"))
@@ -57,6 +74,11 @@ async def to_code(config: ConfigType) -> None:
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None:
enabled = abc if isinstance(abc, bool) else True
settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc
cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE]))
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
{
@@ -70,3 +92,10 @@ automation.register_apply_action(
CALIBRATION_ACTION_SCHEMA,
automation.ApplyCall("calibrate_zero(400)"),
)
for _name, _call in (
("cm1106.abc_enable", "abc_enable()"),
("cm1106.abc_disable", "abc_disable()"),
):
automation.register_apply_action(
_name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call)
)
+12
View File
@@ -8,11 +8,17 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ADC_AVERAGING = "adc_averaging"
CONF_ADC_TIME = "adc_time"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_AQI = "aqi"
CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration"
CONF_B_CONSTANT = "b_constant"
CONF_BLUE_CHANNEL = "blue_channel"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLEAR_CHANNEL = "clear_channel"
CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
@@ -25,8 +31,10 @@ CONF_DESCRIPTION = "description"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
CONF_GREEN_CHANNEL = "green_channel"
CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_HOLD_STATE = "hold_state"
CONF_HOST = "host"
CONF_HUMIDITY_SOURCE = "humidity_source"
CONF_IAQ = "iaq"
@@ -42,9 +50,11 @@ CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_ON_STOP = "on_stop"
CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_RED_CHANNEL = "red_channel"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
@@ -55,6 +65,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_TARGET_COUNT = "target_count"
CONF_USE_PSRAM = "use_psram"
CONF_VERIFY_SSL = "verify_ssl"
CONF_VOC_INDEX = "voc_index"
CONF_VOLUME_INCREMENT = "volume_increment"
CONF_VOLUME_INITIAL = "volume_initial"
@@ -70,3 +81,4 @@ KEY_METADATA = "metadata"
UNIT_AMPERE_HOUR = "Ah"
UNIT_COUNTS = "#"
UNIT_MILLIAMPERE_HOUR = "mAh"
+12 -12
View File
@@ -148,7 +148,7 @@ void CS5460AComponent::schedule_next_check_() {
*/
if (time_left > -15000) {
time_left = 1000;
this->status_momentary_warning("warning", 1000);
this->status_momentary_warning(1000);
} else {
ESP_LOGCONFIG(TAG, "Device officially stuck, resetting");
this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID);
@@ -179,67 +179,67 @@ bool CS5460AComponent::check_status_() {
if (status & (1 << 2)) {
clear |= 1 << 2;
ESP_LOGE(TAG, "Low supply detected");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 3)) {
clear |= 1 << 3;
ESP_LOGE(TAG, "Modulator oscillation on current channel");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 4)) {
clear |= 1 << 4;
ESP_LOGE(TAG, "Modulator oscillation on voltage channel");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 5)) {
clear |= 1 << 5;
ESP_LOGE(TAG, "Watch-dog timeout");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 11)) {
clear |= 1 << 11;
ESP_LOGE(TAG, "EOUT Energy Accumulation Register out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 12)) {
clear |= 1 << 12;
ESP_LOGE(TAG, "Energy out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 13)) {
clear |= 1 << 13;
ESP_LOGE(TAG, "RMS voltage out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 14)) {
clear |= 1 << 14;
ESP_LOGE(TAG, "RMS current out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 15)) {
clear |= 1 << 15;
ESP_LOGE(TAG, "Power calculation out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 16)) {
clear |= 1 << 16;
ESP_LOGE(TAG, "Voltage out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 17)) {
clear |= 1 << 17;
ESP_LOGE(TAG, "Current out of range");
this->status_momentary_warning("warning", 500);
this->status_momentary_warning(500);
}
if (status & (1 << 19)) {
+99 -34
View File
@@ -18,21 +18,40 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc
static constexpr int CSE7761_IREF = 52241; // RmsIAC
static constexpr int CSE7761_PREF = 44513; // PowerPAC
// Empirical Sonoff POW CT factor taken from Tasmota
static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5;
// PGA 16x on both current channels
static constexpr uint16_t SYSCON_SHUNT = 0xFF04;
// All PGAs 1x, ADC2ON
static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00;
// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz
static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f;
static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04)
static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000)
static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000)
static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001)
static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210)
static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000)
static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register
static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000)
static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000)
static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF)
static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000)
static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000)
static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register
static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum
static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient
// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1
static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f;
static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command
static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets
static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation
@@ -57,7 +76,18 @@ void CSE7761Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
ESP_LOGCONFIG(TAG, " Current input: %s",
LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt")));
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current 1", this->current_sensor_1_);
LOG_SENSOR(" ", "Current 2", this->current_sensor_2_);
LOG_SENSOR(" ", "Power 1", this->power_sensor_1_);
LOG_SENSOR(" ", "Power 2", this->power_sensor_2_);
LOG_SENSOR(" ", "Frequency", this->frequency_sensor_);
LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_);
LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]);
LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]);
}
void CSE7761Component::update() {
@@ -73,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) {
buffer[1] = reg;
uint32_t len = 2;
if (data) {
if (data < 0xFF) {
if (data <= 0xFF) {
buffer[2] = data & 0xFF;
len = 3;
} else {
@@ -81,11 +111,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) {
buffer[3] = data & 0xFF;
len = 4;
}
uint8_t crc = 0;
for (uint32_t i = 0; i < len; i++) {
crc += buffer[i];
}
buffer[len] = ~crc;
buffer[len] = ~sum_bytes(buffer, len);
len++;
}
@@ -115,46 +141,44 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) {
}
rcvd--;
if (static_cast<uint8_t>(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) {
return false;
}
uint32_t result = 0;
// CRC check
uint8_t crc = 0xA5 + reg;
for (uint32_t i = 0; i < rcvd; i++) {
result = (result << 8) | buffer[i];
crc += buffer[i];
}
crc = ~crc;
if (crc != buffer[rcvd]) {
return false;
}
*value = result;
return true;
}
uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
bool result = false; // Start loop
uint8_t retry = 3; // Retry up to three times
uint32_t value = 0; // Default no value
while (!result && retry > 0) {
retry--;
if (this->read_once_(reg, size, &value))
return value;
bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) {
for (uint8_t retry = 0; retry < 3; retry++) {
if (this->read_once_(reg, size, value))
return true;
}
ESP_LOGE(TAG, "Reading register %hhu failed!", reg);
return value;
return false;
}
uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
uint32_t value = 0;
return this->read_(reg, size, &value) ? value : 0;
}
uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) {
const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1;
uint32_t coeff = 0;
switch (unit) {
case RMS_UC:
coeff = this->data_.coefficient[RMS_UC];
return coeff ? 0x400000 * 100 / coeff : 0;
case RMS_IAC:
coeff = this->data_.coefficient[RMS_IAC];
coeff = this->data_.coefficient[RMS_IAC] * scale;
return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits
case POWER_PAC:
coeff = this->data_.coefficient[POWER_PAC];
coeff = this->data_.coefficient[POWER_PAC] * scale;
return coeff ? 0x80000000 / coeff : 0;
}
return 0;
@@ -167,21 +191,38 @@ bool CSE7761Component::chip_init_() {
calc_chksum += this->data_.coefficient[i];
}
calc_chksum = ~calc_chksum;
const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr;
uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2);
if ((calc_chksum != coeff_chksum) || (!calc_chksum)) {
ESP_LOGD(TAG, "Default calibration");
this->data_.coefficient[RMS_IAC] = CSE7761_IREF;
this->data_.coefficient[RMS_UC] = CSE7761_UREF;
this->data_.coefficient[POWER_PAC] = CSE7761_PREF;
if (energy_configured) {
ESP_LOGW(TAG, "No energy calibration, energy unavailable");
}
} else if (energy_configured) {
uint32_t hf_const = 0;
if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) {
ESP_LOGW(TAG, "HFConst read failed, energy unavailable");
}
// The pulse counters follow the raw power, so they need the same front end factor as power
const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f;
for (uint8_t channel = 0; channel < 2; channel++) {
uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const;
this->energy_[channel].wh_per_pulse = static_cast<float>(scale) * CSE7761_ENERGY_WH_SCALE * input_scale;
}
}
this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE);
uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1);
if (sys_status & 0x10) { // Write enable to protected registers (WREN)
this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04);
this->write_(CSE7761_REG_SYSCON | 0x80,
this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT);
this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183);
this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1);
// WaveEN|ZxEN, needed for Ufreq
this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1);
this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290);
} else {
ESP_LOGD(TAG, "Write failed at chip_init");
@@ -199,14 +240,15 @@ void CSE7761Component::get_data_() {
uint32_t value = this->read_(CSE7761_REG_RMSU, 3);
this->data_.voltage_rms = (value >= 0x800000) ? 0 : value;
// No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer
value = this->read_(CSE7761_REG_RMSIA, 3);
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPA, 4);
// PowerPA is two's complement signed 32-bit per datasheet
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast<int32_t>(value);
value = this->read_(CSE7761_REG_RMSIB, 3);
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPB, 4);
// PowerPB is two's complement signed 32-bit per datasheet
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast<int32_t>(value);
@@ -218,12 +260,35 @@ void CSE7761Component::get_data_() {
this->voltage_sensor_->publish_state(voltage);
}
uint32_t raw_reg = 0;
if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) {
// Ufreq highest bit set means no signal
float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg;
this->frequency_sensor_->publish_state(frequency);
}
if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) {
// ADC2ON makes the chip report channel A
int24_t pf{static_cast<int32_t>(raw_reg)};
this->power_factor_sensor_1_->publish_state(static_cast<float>(static_cast<int32_t>(pf)) / 0x800000);
}
for (uint8_t channel = 0; channel < 2; channel++) {
// Active power = PowerPA * PowerPAC * 1000 / 0x80000000
float active_power =
static_cast<float>(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W
float amps = static_cast<float>(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A
ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps);
sensor::Sensor *energy_sensor = this->energy_sensors_[channel];
CSE7761EnergyChannel &energy = this->energy_[channel];
uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB;
uint32_t raw = 0;
if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) {
// 24-bit counter, not cleared on read
energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF;
energy.last_pulses = raw;
energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse);
}
if (channel == 0) {
if (this->power_sensor_1_ != nullptr) {
this->power_sensor_1_->publish_state(active_power);
+28 -1
View File
@@ -3,11 +3,11 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/datatypes.h"
namespace esphome::cse7761 {
struct CSE7761DataStruct {
uint32_t frequency = 0;
uint32_t voltage_rms = 0;
uint32_t current_rms[2] = {0};
int32_t active_power[2] = {0};
@@ -15,14 +15,32 @@ struct CSE7761DataStruct {
bool ready = false;
};
struct CSE7761EnergyChannel {
uint32_t last_pulses{0};
uint32_t pulses{0};
float wh_per_pulse{0.0f};
};
enum class CSE7761CurrentInput : uint8_t {
CSE7761_CURRENT_INPUT_SHUNT,
CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
};
/// This class implements support for the CSE7761 UART power sensor.
class CSE7761Component final : public PollingComponent, public uart::UARTDevice {
public:
explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {}
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; }
void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) {
power_factor_sensor_1_ = power_factor_sensor_1;
}
void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; }
void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; }
void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; }
void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; }
void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; }
void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; }
void setup() override;
void dump_config() override;
void update() override;
@@ -30,14 +48,23 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice
protected:
// Sensors
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *frequency_sensor_{nullptr};
sensor::Sensor *power_factor_sensor_1_{nullptr};
sensor::Sensor *power_sensor_1_{nullptr};
sensor::Sensor *current_sensor_1_{nullptr};
sensor::Sensor *power_sensor_2_{nullptr};
sensor::Sensor *current_sensor_2_{nullptr};
sensor::Sensor *energy_sensors_[2]{nullptr, nullptr};
CSE7761DataStruct data_;
CSE7761EnergyChannel energy_[2];
const CSE7761CurrentInput current_input_;
bool is_current_transformer_() const {
return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER;
}
void write_(uint8_t reg, uint16_t data);
bool read_once_(uint8_t reg, uint8_t size, uint32_t *value);
bool read_(uint8_t reg, uint8_t size, uint32_t *value);
uint32_t read_(uint8_t reg, uint8_t size);
uint32_t coefficient_by_unit_(uint32_t unit);
bool chip_init_();
+54 -13
View File
@@ -2,15 +2,22 @@ import esphome.codegen as cg
from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_ID,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_HERTZ,
UNIT_VOLT,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
@@ -26,17 +33,41 @@ CONF_CURRENT_1 = "current_1"
CONF_CURRENT_2 = "current_2"
CONF_ACTIVE_POWER_1 = "active_power_1"
CONF_ACTIVE_POWER_2 = "active_power_2"
CONF_ENERGY_1 = "energy_1"
CONF_ENERGY_2 = "energy_2"
CONF_POWER_FACTOR_1 = "power_factor_1"
CONF_CURRENT_INPUT = "current_input"
CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True)
CURRENT_INPUTS = {
"shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT,
"current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
}
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CSE7761Component),
cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum(
CURRENT_INPUTS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_FREQUENCY): sensor.sensor_schema(
unit_of_measurement=UNIT_HERTZ,
accuracy_decimals=2,
device_class=DEVICE_CLASS_FREQUENCY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema(
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CURRENT_1): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
@@ -61,6 +92,18 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ENERGY_1): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_ENERGY_2): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -79,19 +122,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for key in [
CONF_VOLTAGE,
CONF_CURRENT_1,
CONF_CURRENT_2,
CONF_ACTIVE_POWER_1,
CONF_ACTIVE_POWER_2,
]:
if key not in config:
continue
conf = config[key]
sens = await sensor.new_sensor(conf)
cg.add(getattr(var, f"set_{key}_sensor")(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_FREQUENCY, var.set_frequency_sensor)
await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor)
await sensors(CONF_CURRENT_1, var.set_current_1_sensor)
await sensors(CONF_CURRENT_2, var.set_current_2_sensor)
await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor)
await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor)
await sensors(CONF_ENERGY_1, var.set_energy_1_sensor)
await sensors(CONF_ENERGY_2, var.set_energy_2_sensor)
+5 -7
View File
@@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() {
}
if (index == CSE7766_RAW_DATA_SIZE - 1) {
uint8_t checksum = 0;
for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) {
checksum += this->raw_data_[i];
}
const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3);
if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) {
ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum,
@@ -205,9 +202,10 @@ void CSE7766Component::parse_data_() {
float pf = NAN;
if (apparent_power > 0) {
pf = power / apparent_power;
if (pf < 0 || pf > 1) {
ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf);
pf = NAN;
if (pf > 1) {
// Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity
ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf);
pf = 1.0f;
}
} else if (apparent_power == 0 && power == 0) {
// No load, report ideal power factor
+107 -18
View File
@@ -1,25 +1,40 @@
#include "daikin.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
namespace esphome::daikin {
ESPHOME_LOG_TAG(TAG, "daikin.climate");
static constexpr uint32_t BOOST_TIMEOUT_ID = 0;
void DaikinClimate::setup() {
climate_ir::ClimateIR::setup();
// A boost preset restored from flash gets its timer too
this->update_boost_timer_(false);
}
void DaikinClimate::control(const climate::ClimateCall &call) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
climate_ir::ClimateIR::control(call);
this->update_boost_timer_(was_boost);
}
void DaikinClimate::transmit_state() {
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00,
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00,
0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00};
this->set_presets_(remote_state);
remote_state[21] = this->operation_mode_();
remote_state[22] = this->temperature_();
uint16_t fan_speed = this->fan_speed_();
remote_state[24] = fan_speed >> 8;
remote_state[25] = fan_speed & 0xff;
// Calculate checksum
for (int i = 16; i < 34; i++) {
remote_state[34] += remote_state[i];
}
remote_state[7] = sum_bytes(remote_state, 7);
remote_state[34] = sum_bytes(remote_state + 16, 18);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
@@ -136,18 +151,52 @@ uint8_t DaikinClimate::temperature_() const {
case climate::CLIMATE_MODE_DRY:
return 0xc0;
default:
uint8_t temperature = (uint8_t) roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX));
return temperature << 1;
return static_cast<uint8_t>(
roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f));
}
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
void DaikinClimate::set_presets_(uint8_t *remote_state) const {
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
case climate::CLIMATE_PRESET_BOOST:
remote_state[29] = DAIKIN_PRESET_POWERFUL_ON;
break;
case climate::CLIMATE_PRESET_SLEEP:
remote_state[29] = DAIKIN_PRESET_QUIET_ON;
break;
case climate::CLIMATE_PRESET_ECO:
remote_state[32] = DAIKIN_PRESET_ECONO_ON;
break;
case climate::CLIMATE_PRESET_ACTIVITY:
remote_state[32] = DAIKIN_PRESET_SENSOR_ON;
break;
case climate::CLIMATE_PRESET_COMFORT:
remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame
break;
default:
break;
}
if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum)
return false;
}
void DaikinClimate::update_boost_timer_(bool was_boost) {
const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
if (is_boost && !was_boost) {
this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); });
} else if (was_boost && !is_boost) {
this->cancel_timeout(BOOST_TIMEOUT_ID);
}
}
void DaikinClimate::cancel_boost_mode_() {
if (this->preset != climate::CLIMATE_PRESET_BOOST)
return;
ESP_LOGD(TAG, "Boost ended");
this->preset = climate::CLIMATE_PRESET_NONE;
this->publish_state();
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
uint8_t mode = frame[5];
// Temperature is given in degrees celcius * 2
// only update for states that use the temperature
@@ -206,16 +255,42 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
this->fan_mode = climate::CLIMATE_FAN_QUIET;
break;
}
// Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid
// A comfort frame whose own state frame never came must not attach to a later transmission
const uint32_t now = App.get_loop_component_start_time();
const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS
? this->pending_comfort_
: PendingComfort::PENDING_COMFORT_UNKNOWN;
this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN;
if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) {
this->preset = climate::CLIMATE_PRESET_BOOST;
} else if (frame[13] & DAIKIN_PRESET_QUIET_ON) {
this->preset = climate::CLIMATE_PRESET_SLEEP;
} else if (frame[16] & DAIKIN_PRESET_ECONO_ON) {
this->preset = climate::CLIMATE_PRESET_ECO;
} else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) {
this->preset = climate::CLIMATE_PRESET_ACTIVITY;
} else if (comfort == PendingComfort::PENDING_COMFORT_ON) {
this->preset = climate::CLIMATE_PRESET_COMFORT;
} else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) {
// Without a comfort frame in this transmission a current comfort preset is left alone
this->preset = climate::CLIMATE_PRESET_NONE;
}
this->update_boost_timer_(was_boost);
this->publish_state();
return true;
}
bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {};
uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
return false;
}
for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) {
uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE;
for (uint8_t pos = 0; pos < frame_size; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) {
@@ -224,7 +299,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
}
state_frame[pos] = byte;
frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11)
@@ -243,11 +318,25 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
} else if (pos == 4) {
// frame type
if (byte != 0x00)
if (byte == DAIKIN_FRAME_TYPE_COMFORT) {
frame_size = DAIKIN_COMFORT_FRAME_SIZE;
} else if (byte != DAIKIN_FRAME_TYPE_STATE) {
return false;
}
}
}
return this->parse_state_frame_(state_frame);
if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) {
return false;
}
if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) {
// Nothing changes yet: the state frame that follows applies it once it has passed its own checksum
this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON
: PendingComfort::PENDING_COMFORT_OFF;
this->pending_comfort_at_ = App.get_loop_component_start_time();
return true;
}
return this->parse_state_frame_(frame);
}
} // namespace esphome::daikin
+34 -6
View File
@@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50;
const uint8_t DAIKIN_FAN_4 = 0x60;
const uint8_t DAIKIN_FAN_5 = 0x70;
// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo)
const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10;
const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01;
const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20;
const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04;
const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02;
// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity
const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000;
// The three frames of one transmission arrive well inside this window
const uint32_t DAIKIN_TRANSMISSION_MS = 1000;
// IR Transmission
const uint32_t DAIKIN_IR_FREQUENCY = 38000;
const uint32_t DAIKIN_HEADER_MARK = 3360;
@@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370;
const uint32_t DAIKIN_ZERO_SPACE = 360;
const uint32_t DAIKIN_MESSAGE_SPACE = 32300;
// State Frame size
// Frame types (byte 4) and sizes
const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00;
const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5;
const uint8_t DAIKIN_STATE_FRAME_SIZE = 19;
const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8;
class DaikinClimate final : public climate_ir::ClimateIR {
public:
DaikinClimate()
: climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
: climate_ir::ClimateIR(
DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_BOTH},
{climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT,
climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {}
protected:
void setup() override;
void control(const climate::ClimateCall &call) override;
// Transmit via IR the state of this climate controller.
void transmit_state() override;
uint8_t operation_mode_() const;
uint16_t fan_speed_() const;
uint8_t temperature_() const;
void set_presets_(uint8_t *remote_state) const;
/// Arms or cancels the boost timer when the preset moves into or out of boost.
void update_boost_timer_(bool was_boost);
void cancel_boost_mode_();
// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
/// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame
enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON };
PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN};
uint32_t pending_comfort_at_{0};
};
} // namespace esphome::daikin
+1 -4
View File
@@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() {
}
bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1);
if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) {
ESP_LOGI(TAG, "checksum error");
return false;
+4
View File
@@ -6,6 +6,8 @@ from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CONF_SUB_UNIT = "sub_unit"
daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc")
DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR)
@@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend(
{
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5),
}
)
@@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT]))
cg.add(var.set_sub_unit(config[CONF_SUB_UNIT]))
+10 -7
View File
@@ -1,5 +1,6 @@
#include "daikin_brc.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::daikin_brc {
@@ -19,13 +20,18 @@ void DaikinBrcClimate::transmit_state() {
0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x20, 0x00};
// The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame
remote_state[3] |= this->sub_unit_;
remote_state[10] |= this->sub_unit_;
remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3
remote_state[12] = this->alt_mode_();
remote_state[13] = this->mode_button_;
remote_state[14] = this->operation_mode_();
remote_state[17] = this->temperature_();
remote_state[18] = this->fan_speed_swing_();
// Calculate checksum
// Calculate state frame checksum
for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) {
remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i];
}
@@ -160,10 +166,7 @@ uint8_t DaikinBrcClimate::temperature_() {
}
bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1);
if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) {
ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum);
return false;
@@ -256,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) {
if (byte != 0x17)
return false;
} else if (pos == 3) {
// frame header
if (byte != 0x18)
// frame header, with this unit's address in the low 3 bits
if (byte != (0x18 | this->sub_unit_))
return false;
} else if (pos == 4) {
// frame type
@@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
this->fahrenheit_ = value;
this->temperature_step_ = value ? 0.5f : 1.0f;
}
/// Which indoor unit (0 to 5) this climate addresses on a multi-unit system
void set_sub_unit(uint8_t value) { this->sub_unit_ = value; }
protected:
uint8_t mode_button_ = 0x00;
@@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
bool fahrenheit_{false};
uint8_t sub_unit_{0};
};
} // namespace esphome::daikin_brc
+1 -6
View File
@@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector<uint8_t> data) {
while ((it = std::find(it, data.end(), 0xA5)) != data.end()) {
if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) {
uint8_t checksum;
int sum = 0;
for (int i = 0; i < 12; i++) {
sum += it[i];
}
checksum = sum;
const uint8_t checksum = sum_bytes(&*it, 12);
if (checksum == it[12]) {
switch (it[2]) {
+10 -10
View File
@@ -43,9 +43,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase)
TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase)
DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase)
DateTimeStateTrigger = datetime_ns.class_(
"DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime)
)
OnTimeTrigger = datetime_ns.class_(
"OnTimeTrigger", automation.Trigger, cg.Component, cg.Parented.template(TimeEntity)
@@ -75,11 +72,7 @@ def _validate_time_present(config: ConfigType) -> ConfigType:
_DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
cv.Schema(
{
cv.Optional(CONF_ON_VALUE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DateTimeStateTrigger),
}
),
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
}
)
@@ -144,9 +137,16 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None:
await mqtt.register_mqtt_component(mqtt_, config)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
# The state callback carries nothing; the automation gets the value as an ESPTime.
for conf in config.get(CONF_ON_VALUE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(cg.ESPTime, "x")], conf)
await automation.build_callback_automation(
var,
"add_on_state_callback",
[(cg.ESPTime, "x")],
conf,
params=[],
forward=[automation.parent_ref(var).state_as_esptime()],
)
if CONF_TIME_ID in config:
rtc = await cg.get_variable(config[CONF_TIME_ID])
@@ -31,14 +31,4 @@ class DateTimeBase : public EntityBase {
#endif
};
class DateTimeStateTrigger final : public Trigger<ESPTime> {
public:
explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) {
parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); });
}
protected:
DateTimeBase *parent_;
};
} // namespace esphome::datetime
+1 -1
View File
@@ -58,7 +58,7 @@ void DebugComponent::loop() {
if (new_free_heap < this->free_heap_ / 2) {
this->free_heap_ = new_free_heap;
ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_);
this->status_momentary_warning("heap", 1000);
this->status_momentary_warning(1000);
}
#ifdef USE_SENSOR
+10 -9
View File
@@ -1,6 +1,8 @@
#include "debug_component.h"
#ifdef USE_ZEPHYR
#include <cinttypes>
#include <climits>
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/log.h"
#include <esphome/components/zephyr/reset_reason.h>
#include <zephyr/drivers/hwinfo.h>
@@ -399,18 +401,17 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
#endif
}
#endif
auto uicr = [](volatile uint32_t *data, uint8_t size) {
std::string res;
// 13 words of 8 hex digits, separated by spaces
char uicr_buf[13 * 9];
auto uicr = [&uicr_buf](volatile uint32_t *data, uint8_t size) {
size_t len = 0;
for (size_t i = 0; i < size; i++) {
if (i > 0) {
res += ' ';
}
res += format_hex_pretty<uint32_t>(data[i], '\0', false);
len = buf_append_printf(uicr_buf, sizeof(uicr_buf), len, i > 0 ? " %08" PRIX32 : "%08" PRIX32, data[i]);
}
return res;
return uicr_buf;
};
ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str());
ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str());
ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13));
ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12));
#ifdef ESPHOME_LOG_HAS_VERBOSE
log_peripherals_info();
#endif
@@ -111,7 +111,17 @@ void DeepSleepComponent::deep_sleep_() {
if (this->sleep_duration_.has_value())
esp_sleep_enable_timer_wakeup(*this->sleep_duration_);
// Single pin wakeup (ext0) - ESP32, S2, S3 only
#ifndef SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP
#ifdef USE_GPIO_HOLD
// Variants without per-pin deep sleep hold need the global enable
gpio_deep_sleep_hold_en();
#else
if (this->wakeup_pin_ != nullptr)
gpio_deep_sleep_hold_en();
#endif
#endif
// Single pin wakeup (ext0) - ESP32, S2, S3 only
#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \
!defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2)
@@ -124,11 +134,6 @@ void DeepSleepComponent::deep_sleep_() {
}
gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT);
gpio_hold_en(gpio_pin);
#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP
// Some ESP32 variants support holding a single GPIO during deep sleep without this function
// For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep
gpio_deep_sleep_hold_en();
#endif
bool level = !this->wakeup_pin_->is_inverted();
if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) {
level = !level;
+2 -4
View File
@@ -1,5 +1,6 @@
#include "delonghi.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::delonghi {
@@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() {
}
bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1);
if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) {
return false;
}
@@ -33,7 +33,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component {
case ACP_STATE_ARMED_AWAY:
if (this->get_requires_code_to_arm()) {
if (!code.has_value() || *code != "1234") {
this->status_momentary_error("invalid_code", 5000);
this->status_momentary_error(5000);
return;
}
}
@@ -42,7 +42,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component {
case ACP_STATE_DISARMED:
if (this->get_requires_code()) {
if (!code.has_value() || *code != "1234") {
this->status_momentary_error("invalid_code", 5000);
this->status_momentary_error(5000);
return;
}
}
+14 -16
View File
@@ -21,7 +21,6 @@ from esphome.const import (
CONF_PAGES,
CONF_ROTATION,
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
@@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage")
DisplayPagePtr = DisplayPage.operator("ptr")
DisplayRef = Display.operator("ref")
DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action)
DisplayOnPageChangeTrigger = display_ns.class_(
"DisplayOnPageChangeTrigger", automation.Trigger
)
CONF_ON_PAGE_CHANGE = "on_page_change"
CONF_SHOW_TEST_CARD = "show_test_card"
CONF_UNSPECIFIED = "unspecified"
_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT")
DISPLAY_ROTATIONS = {
0: display_ns.DISPLAY_ROTATION_0_DEGREES,
90: display_ns.DISPLAY_ROTATION_90_DEGREES,
@@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend(
),
cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayOnPageChangeTrigger
),
cv.Optional(CONF_FROM): cv.use_id(DisplayPage),
cv.Optional(CONF_TO): cv.use_id(DisplayPage),
}
@@ -137,15 +132,18 @@ async def setup_display_core_(var, config):
pages.append(page)
cg.add(var.set_pages(pages))
for conf in config.get(CONF_ON_PAGE_CHANGE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if CONF_FROM in conf:
page = await cg.get_variable(conf[CONF_FROM])
cg.add(trigger.set_from(page))
if CONF_TO in conf:
page = await cg.get_variable(conf[CONF_TO])
cg.add(trigger.set_to(page))
await automation.build_automation(
trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf
_request_page_change_slot(str(var))
checks = []
for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")):
if (page_id := conf.get(key)) is not None:
page = await cg.get_variable(page_id)
checks.append(f"{name} == ::{page}")
await automation.build_callback_automation(
var,
"add_on_page_change_callback",
[(DisplayPagePtr, "from"), (DisplayPagePtr, "to")],
conf,
when=" && ".join(checks) or None,
)
if config.get(CONF_SHOW_TEST_CARD):
cg.add(var.show_test_card())
+7 -10
View File
@@ -678,12 +678,14 @@ void Display::set_pages(std::vector<DisplayPage *> pages) {
}
void Display::show_page(DisplayPage *page) {
this->previous_page_ = this->page_;
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
DisplayPage *previous = this->page_;
this->page_ = page;
if (this->previous_page_ != this->page_) {
for (auto *t : on_page_change_triggers_)
t->process(this->previous_page_, this->page_);
}
if (previous != page)
this->page_change_callback_.call(previous, page);
#else
this->page_ = page;
#endif
}
void Display::do_update_() {
@@ -700,11 +702,6 @@ void Display::do_update_() {
this->clear_clipping_();
}
void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) {
if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to))
this->trigger(from, to);
}
void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format,
ESPTime time) {
char buffer[64];
+12 -18
View File
@@ -7,6 +7,8 @@
#include "esphome/core/color.h"
#include "esphome/core/automation.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/time.h"
#include "esphome/core/log.h"
#include "display_color_utils.h"
@@ -173,7 +175,6 @@ enum RegularPolygonDrawing {
class Display;
class DisplayPage;
class DisplayOnPageChangeTrigger;
/** Optimized display writer that uses function pointers for stateless lambdas.
*
@@ -294,7 +295,7 @@ using display_writer_t = DisplayWriter<Display>;
prefix type "\n" \
"%s Rotations: %d °\n" \
"%s Dimensions: %dpx x %dpx", \
prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \
prefix, log_display_obj->get_rotation(), prefix, log_display_obj->get_width(), \
log_display_obj->get_height()); \
}
@@ -702,7 +703,11 @@ class Display : public PollingComponent {
const DisplayPage *get_active_page() const { return this->page_; }
void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); }
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
template<typename F> void add_on_page_change_callback(F &&callback) {
this->page_change_callback_.add(std::forward<F>(callback));
}
#endif
/// Internal method to set the display rotation with.
virtual void set_rotation(DisplayRotation rotation);
@@ -710,7 +715,7 @@ class Display : public PollingComponent {
// Internal method to set display auto clearing.
void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; }
DisplayRotation get_rotation() const { return this->rotation_; }
virtual DisplayRotation get_rotation() const { return this->rotation_; }
/** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays,
* returns the type the display is currently configured to.
@@ -809,8 +814,9 @@ class Display : public PollingComponent {
DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES};
display_writer_t writer_{};
DisplayPage *page_{nullptr};
DisplayPage *previous_page_{nullptr};
std::vector<DisplayOnPageChangeTrigger *> on_page_change_triggers_;
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
StaticCallbackManager<DISPLAY_PAGE_CHANGE_CALLBACK_COUNT, void(DisplayPage *, DisplayPage *)> page_change_callback_;
#endif
bool auto_clear_enabled_{true};
std::vector<Rect> clipping_rectangle_;
uint8_t wdt_pixel_counter_{0};
@@ -850,18 +856,6 @@ template<typename... Ts> class DisplayPageShowAction final : public Action<Ts...
}
};
class DisplayOnPageChangeTrigger final : public Trigger<DisplayPage *, DisplayPage *> {
public:
explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); }
void process(DisplayPage *from, DisplayPage *to);
void set_from(DisplayPage *p) { this->from_ = p; }
void set_to(DisplayPage *p) { this->to_ = p; }
protected:
DisplayPage *from_{nullptr};
DisplayPage *to_{nullptr};
};
const LogString *text_align_to_string(TextAlign textalign);
} // namespace esphome::display
@@ -20,9 +20,10 @@ from esphome.const import (
CONF_ON_VALUE,
CONF_SWITCH,
CONF_TEXT,
CONF_TRIGGER_ID,
CONF_TYPE,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@numo68"]
DOMAIN = "display_menu_base"
@@ -86,26 +87,6 @@ MENU_MODES = {
CONF_JOYSTICK: MenuMode.MENU_MODE_JOYSTICK,
}
DisplayMenuOnEnterTrigger = display_menu_base_ns.class_(
"DisplayMenuOnEnterTrigger", automation.Trigger
)
DisplayMenuOnLeaveTrigger = display_menu_base_ns.class_(
"DisplayMenuOnLeaveTrigger", automation.Trigger
)
DisplayMenuOnValueTrigger = display_menu_base_ns.class_(
"DisplayMenuOnValueTrigger", automation.Trigger
)
DisplayMenuOnNextTrigger = display_menu_base_ns.class_(
"DisplayMenuOnNextTrigger", automation.Trigger
)
DisplayMenuOnPrevTrigger = display_menu_base_ns.class_(
"DisplayMenuOnPrevTrigger", automation.Trigger
)
def validate_format(format):
if re.search(r"^%[+-]*(\d+)?(\.\d+)?[fg]$", format) is None:
@@ -129,44 +110,20 @@ MENU_ITEM_COMMON_SCHEMA = cv.Schema(
MENU_ITEM_ENTER_LEAVE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend(
{
cv.Optional(CONF_ON_ENTER): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnEnterTrigger
),
}
),
cv.Optional(CONF_ON_LEAVE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnLeaveTrigger
),
}
),
cv.Optional(CONF_ON_ENTER): automation.validate_automation({}),
cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}),
}
)
MENU_ITEM_VALUE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend(
{
cv.Optional(CONF_ON_VALUE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnValueTrigger
),
}
),
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
}
)
MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA = MENU_ITEM_ENTER_LEAVE_SCHEMA.extend(
{
cv.Optional(CONF_ON_VALUE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnValueTrigger
),
}
),
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
}
)
@@ -230,20 +187,8 @@ MENU_ITEM_SCHEMA = cv.typed_schema(
cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCustom),
cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean,
cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_ON_NEXT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnNextTrigger
),
}
),
cv.Optional(CONF_ON_PREV): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnPrevTrigger
),
}
),
cv.Optional(CONF_ON_NEXT): automation.validate_automation({}),
cv.Optional(CONF_ON_PREV): automation.validate_automation({}),
}
),
},
@@ -256,20 +201,8 @@ DISPLAY_MENU_BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.GenerateID(CONF_ROOT_ITEM_ID): cv.declare_id(MenuItemMenu),
cv.Optional(CONF_MODE, default=CONF_ROTARY): cv.enum(MENU_MODES),
cv.Optional(CONF_ON_ENTER): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnEnterTrigger
),
}
),
cv.Optional(CONF_ON_LEAVE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayMenuOnLeaveTrigger
),
}
),
cv.Optional(CONF_ON_ENTER): automation.validate_automation({}),
cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}),
cv.Required(CONF_ITEMS): cv.All(
cv.ensure_list(MENU_ITEM_SCHEMA), cv.Length(min=1)
),
@@ -302,6 +235,24 @@ automation.register_apply_condition(
)
# Each item callback carries nothing; the automation receives the item itself.
_ITEM_CALLBACKS = (
(CONF_ON_ENTER, "add_on_enter_callback"),
(CONF_ON_LEAVE, "add_on_leave_callback"),
(CONF_ON_VALUE, "add_on_value_callback"),
(CONF_ON_NEXT, "add_on_next_callback"),
(CONF_ON_PREV, "add_on_prev_callback"),
)
async def _build_item_automations(item: MockObj, config: ConfigType) -> None:
for conf_key, callback_method in _ITEM_CALLBACKS:
for conf in config.get(conf_key, []):
await automation.build_parent_callback_automation(
item, callback_method, (MenuItemConstPtr, "it"), conf
)
async def menu_item_to_code(menu, config, parent):
if config[CONF_TYPE] in MENU_ITEMS_WITH_SPECIALIZED_CLASSES:
item = cg.new_Pvariable(config[CONF_ID])
@@ -340,21 +291,7 @@ async def menu_item_to_code(menu, config, parent):
cg.add(item.set_switch_variable(var))
cg.add(item.set_on_text(config[CONF_ON_TEXT]))
cg.add(item.set_off_text(config[CONF_OFF_TEXT]))
for conf in config.get(CONF_ON_ENTER, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
for conf in config.get(CONF_ON_LEAVE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
for conf in config.get(CONF_ON_VALUE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
for conf in config.get(CONF_ON_NEXT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
for conf in config.get(CONF_ON_PREV, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
await _build_item_automations(item, config)
async def display_menu_to_code(menu, config):
@@ -364,9 +301,4 @@ async def display_menu_to_code(menu, config):
cg.add(menu.set_mode(config[CONF_MODE]))
for c in config[CONF_ITEMS]:
await menu_item_to_code(menu, c, root_item)
for conf in config.get(CONF_ON_ENTER, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
for conf in config.get(CONF_ON_LEAVE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item)
await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf)
await _build_item_automations(root_item, config)
@@ -1,58 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "display_menu_base.h"
namespace esphome::display_menu_base {
class DisplayMenuOnEnterTrigger final : public Trigger<const MenuItem *> {
public:
explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) {
parent->add_on_enter_callback([this]() { this->trigger(this->parent_); });
}
protected:
MenuItem *parent_;
};
class DisplayMenuOnLeaveTrigger final : public Trigger<const MenuItem *> {
public:
explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) {
parent->add_on_leave_callback([this]() { this->trigger(this->parent_); });
}
protected:
MenuItem *parent_;
};
class DisplayMenuOnValueTrigger final : public Trigger<const MenuItem *> {
public:
explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) {
parent->add_on_value_callback([this]() { this->trigger(this->parent_); });
}
protected:
MenuItem *parent_;
};
class DisplayMenuOnNextTrigger final : public Trigger<const MenuItem *> {
public:
explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) {
parent->add_on_next_callback([this]() { this->trigger(this->parent_); });
}
protected:
MenuItemCustom *parent_;
};
class DisplayMenuOnPrevTrigger final : public Trigger<const MenuItem *> {
public:
explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) {
parent->add_on_prev_callback([this]() { this->trigger(this->parent_); });
}
protected:
MenuItemCustom *parent_;
};
} // namespace esphome::display_menu_base
+64 -160
View File
@@ -1,12 +1,8 @@
import logging
import re
from typing import Any
import esphome.codegen as cg
from esphome.components import esp32, uart
from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN
from esphome.components.text_sensor import DOMAIN as TEXT_SENSOR_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -16,10 +12,9 @@ from esphome.const import (
CONF_RECEIVE_TIMEOUT,
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"]
DEPENDENCIES = ["uart"]
DOMAIN = "dlms_meter"
@@ -33,19 +28,17 @@ CONF_SKIP_CRC = "skip_crc"
CONF_DEFAULT_OBIS = "default_obis"
CONF_PROVIDER = "provider"
DLMS_PARSER_VERSION = "2.2.0"
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
DlmsMeterComponent = dlms_meter_component_ns.class_(
"DlmsMeterComponent", cg.Component, uart.UARTDevice
)
CustomPattern = dlms_meter_component_ns.struct("CustomPattern")
ObisId = cg.global_ns.namespace("dlms_parser").class_("ObisId")
def obis_code(value: Any) -> str:
# Normalize the OBIS code to the strict A.B.C.D.E.F format
bytes_list = parse_obis_code_bytes(value)
return ".".join(str(b) for b in bytes_list)
def parse_obis_code_bytes(value: Any) -> list[int]:
def obis_string_to_byte_list(value: Any) -> list[int]:
value = cv.string(value)
normalized = re.sub(r"[\-\:\*]", ".", value)
parts = normalized.split(".")
@@ -63,82 +56,51 @@ def parse_obis_code_bytes(value: Any) -> list[int]:
return bytes_list
def to_obis_id_struct(value: list[int]) -> cg.Expression:
return ObisId(*value)
_request_sensor_slot = cg.slot_counter("DLMS_MAX_SENSORS")
_request_text_sensor_slot = cg.slot_counter("DLMS_MAX_TEXT_SENSORS")
_request_binary_sensor_slot = cg.slot_counter("DLMS_MAX_BINARY_SENSORS")
def register_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None:
_request_sensor_slot(str(hub))
cg.add(hub.register_sensor(to_obis_id_struct(obis), var))
def register_text_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None:
_request_text_sensor_slot(str(hub))
cg.add(hub.register_text_sensor(to_obis_id_struct(obis), var))
def register_binary_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None:
_request_binary_sensor_slot(str(hub))
cg.add(hub.register_binary_sensor(to_obis_id_struct(obis), var))
def custom_pattern_dict(value: Any) -> ConfigType:
if isinstance(value, str):
return {CONF_PATTERN: value}
return value
def validate_custom_pattern(value: ConfigType) -> ConfigType:
if CONF_DEFAULT_OBIS in value and CONF_NAME not in value:
raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set")
return value
def validate_provider_deprecation(config: ConfigType) -> ConfigType:
if CONF_PROVIDER in config:
provider = str(config[CONF_PROVIDER]).lower()
if provider == "netznoe":
_LOGGER.warning(
"The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. "
"The required custom patterns have been added automatically for this release, but you must update your configuration.\n"
"Please remove the 'provider' key and explicitly replace it with the following:\n\n"
"custom_patterns:\n"
' - pattern: "L, TSTR"\n'
' name: "MeterID"\n'
' default_obis: "0.0.96.1.0.255"\n'
' - pattern: "F, TDTM"\n'
' name: "DateTime"\n'
' default_obis: "0.0.1.0.0.255"\n'
)
patterns = config.get(CONF_CUSTOM_PATTERNS, [])
# Ensure "L, TSTR" for MeterID is present
if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns):
patterns.append(
{
CONF_PATTERN: "L, TSTR",
CONF_NAME: "MeterID",
CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255],
CONF_PRIORITY: 0,
}
)
# Ensure "F, TDTM" for DateTime is present
if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns):
patterns.append(
{
CONF_PATTERN: "F, TDTM",
CONF_NAME: "DateTime",
CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255],
CONF_PRIORITY: 0,
}
)
config[CONF_CUSTOM_PATTERNS] = patterns
else:
_LOGGER.warning(
"The 'provider' option is deprecated and will be removed in 2026.11.0. "
"The dlms_parser library now handles quirks dynamically. "
"Please remove this option from your configuration."
)
return config
CUSTOM_PATTERN_SCHEMA = cv.All(
custom_pattern_dict,
cv.Schema(
{
cv.Required(CONF_PATTERN): cv.string,
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_NAME, default="CUSTOM"): cv.string,
cv.Optional(CONF_PRIORITY, default=0): cv.int_,
cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes,
cv.Optional(
CONF_DEFAULT_OBIS, default="0.0.0.0.0.0"
): obis_string_to_byte_list,
}
),
validate_custom_pattern,
)
CONFIG_SCHEMA = cv.All(
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DlmsMeterComponent),
@@ -146,115 +108,57 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"),
cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA),
cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean,
cv.Optional(CONF_PROVIDER): cv.string,
# Removed in 2026.11.0 - kept to provide helpful error message
# Remove before 2027.5.0
cv.Optional(CONF_PROVIDER): cv.invalid(
"The 'provider' option has been removed in ESPHome 2026.11.0.\n"
"For 'provider: netznoe', replace it with:\n\n"
"custom_patterns:\n"
' - pattern: "L, TSTR"\n'
' name: "MeterID"\n'
' default_obis: "0.0.96.1.0.255"\n'
' - pattern: "F, TDTM"\n'
' name: "DateTime"\n'
' default_obis: "0.0.1.0.0.255"\n\n'
"For any other provider, remove the option"
),
cv.Optional(
CONF_RECEIVE_TIMEOUT, default="1000ms"
): cv.positive_time_period_milliseconds,
}
)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
validate_provider_deprecation,
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True)
async def to_code(config: ConfigType) -> None:
dec_key_expr = cg.RawExpression("std::nullopt")
if dec_key := config.get(CONF_DECRYPTION_KEY):
key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)]
dec_key_expr = cg.RawExpression(
f"std::array<uint8_t, 16>{{{', '.join(key_bytes)}}}"
custom_patterns = [
cg.StructInitializer(
CustomPattern,
(CONF_PATTERN, pattern[CONF_PATTERN]),
(CONF_NAME, pattern[CONF_NAME]),
(CONF_PRIORITY, pattern[CONF_PRIORITY]),
(CONF_DEFAULT_OBIS, to_obis_id_struct(pattern[CONF_DEFAULT_OBIS])),
)
auth_key_expr = cg.RawExpression("std::nullopt")
if auth_key := config.get(CONF_AUTH_KEY):
key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)]
auth_key_expr = cg.RawExpression(
f"std::array<uint8_t, 16>{{{', '.join(key_bytes)}}}"
)
patterns = []
if custom_patterns := config.get(CONF_CUSTOM_PATTERNS):
for p in custom_patterns:
name_expr = cg.RawExpression("std::nullopt")
if name_val := p.get(CONF_NAME):
name_expr = name_val
if obis_vals := p.get(CONF_DEFAULT_OBIS):
obis_expr = cg.RawExpression(
f"std::array<uint8_t, 6>{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}"
)
else:
obis_expr = cg.RawExpression("std::nullopt")
patterns.append(
cg.ArrayInitializer(
p[CONF_PATTERN],
name_expr,
p.get(CONF_PRIORITY, 0),
obis_expr,
)
)
patterns_expr = (
cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}")
)
for pattern in config.get(CONF_CUSTOM_PATTERNS, [])
]
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_RECEIVE_TIMEOUT],
config[CONF_SKIP_CRC],
dec_key_expr,
auth_key_expr,
patterns_expr,
config.get(CONF_DECRYPTION_KEY, cg.nullptr),
config.get(CONF_AUTH_KEY, cg.nullptr),
cg.ArrayInitializer(*custom_patterns, multiline=True),
)
hub_id = config[CONF_ID].id
sensor_count = 0
for sens_conf in CORE.config.get(SENSOR_DOMAIN, []):
if (
sens_conf.get("platform") == DOMAIN
and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
):
if CONF_OBIS_CODE in sens_conf:
sensor_count += 1
else:
from .sensor import NUMERIC_KEYS
sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf)
text_sensor_count = 0
for sens_conf in CORE.config.get(TEXT_SENSOR_DOMAIN, []):
if (
sens_conf.get("platform") == DOMAIN
and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
):
if CONF_OBIS_CODE in sens_conf:
text_sensor_count += 1
else:
from .text_sensor import TEXT_KEYS
text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf)
binary_sensor_count = 0
for sens_conf in CORE.config.get(BINARY_SENSOR_DOMAIN, []):
if (
sens_conf.get("platform") == DOMAIN
and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
):
binary_sensor_count += 1
cg.add_define("DLMS_MAX_SENSORS", sensor_count)
cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count)
cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if CORE.is_esp32:
esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0")
esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION)
else:
cg.add_library("esphome/dlms_parser", "1.1.0")
cg.add_library("esphome/dlms_parser", DLMS_PARSER_VERSION)
@@ -3,14 +3,20 @@ from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
from .. import (
CONF_DLMS_METER_ID,
CONF_OBIS_CODE,
DlmsMeterComponent,
obis_string_to_byte_list,
register_binary_sensor,
)
DEPENDENCIES = ["dlms_meter"]
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
cv.Required(CONF_OBIS_CODE): obis_code,
cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list,
}
)
@@ -18,4 +24,4 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
var = await binary_sensor.new_binary_sensor(config)
cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var))
register_binary_sensor(hub, config[CONF_OBIS_CODE], var)
+79 -86
View File
@@ -1,6 +1,11 @@
// Ignore Zephyr. It doesn't have any encryption library.
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
#include "dlms_meter.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cstdio>
namespace esphome::dlms_meter {
@@ -31,54 +36,40 @@ static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list a
}
}
DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check,
std::optional<std::array<uint8_t, 16>> decryption_key,
std::optional<std::array<uint8_t, 16>> authentication_key,
DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const bool skip_crc_check,
const char *decryption_key, const char *authentication_key,
std::vector<CustomPattern> custom_patterns)
: receive_timeout_ms_(receive_timeout_ms),
skip_crc_check_(skip_crc_check),
custom_patterns_(std::move(custom_patterns)),
parser_(&decryptor_) {
parser_([this](const dlms_parser::AxdrCapture &capture) { this->on_data_(capture); }, &this->decryptor_) {
dlms_parser::Logger::set_log_function(log_callback);
if (decryption_key.has_value()) {
#ifdef DLMS_METER_NO_CRYPTO
ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)");
#else
auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value());
if (opt_key) {
this->parser_.set_decryption_key(*opt_key);
if (decryption_key != nullptr) {
const auto key = dlms_parser::Aes128GcmDecryptionKey::from_hex(decryption_key);
if (key.has_value()) {
this->parser_.set_decryption_key(*key);
} else {
ESP_LOGE(TAG, "Failed to set decryption key: invalid key format");
}
#endif
}
if (authentication_key.has_value()) {
#ifdef DLMS_METER_NO_CRYPTO
ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)");
#else
auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value());
if (opt_key) {
this->parser_.set_authentication_key(*opt_key);
if (authentication_key != nullptr) {
const auto key = dlms_parser::Aes128GcmAuthenticationKey::from_hex(authentication_key);
if (key.has_value()) {
this->parser_.set_authentication_key(*key);
} else {
ESP_LOGE(TAG, "Failed to set authentication key: invalid key format");
}
#endif
}
this->parser_.set_skip_crc_check(this->skip_crc_check_);
this->parser_.load_default_patterns();
for (const auto &pattern : this->custom_patterns_) {
if (pattern.default_obis.has_value() && pattern.name.has_value()) {
this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority,
pattern.default_obis.value());
} else if (pattern.name.has_value()) {
this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority);
} else {
this->parser_.register_pattern(pattern.pattern.c_str());
}
for (const auto &custom_pattern : this->custom_patterns_) {
this->parser_.register_pattern(custom_pattern.name, custom_pattern.pattern, custom_pattern.priority,
custom_pattern.default_obis);
}
}
@@ -89,36 +80,36 @@ void DlmsMeterComponent::dump_config() {
ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_);
ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_));
for (const auto &pattern : this->custom_patterns_) {
if (pattern.default_obis.has_value() && pattern.name.has_value()) {
const auto &obis = pattern.default_obis.value();
ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)",
pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2],
obis[3], obis[4], obis[5]);
} else if (pattern.name.has_value()) {
ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(),
pattern.name->c_str(), pattern.priority);
} else {
ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str());
}
std::array<char, 24> obis_buf;
for (const auto &custom_pattern : this->custom_patterns_) {
const auto default_obis = custom_pattern.default_obis.to_string(obis_buf);
ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %.*s)", custom_pattern.pattern,
custom_pattern.name, custom_pattern.priority, static_cast<int>(default_obis.size()),
default_obis.data());
}
#ifdef USE_SENSOR
for (const auto &entry : this->sensors_) {
LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor);
ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
for (const auto &item : this->sensors_) {
LOG_SENSOR(" ", "Numeric Sensor (OBIS)", item.sensor);
const auto obis = item.obis.to_string(obis_buf);
ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast<int>(obis.size()), obis.data());
}
#endif
#ifdef USE_TEXT_SENSOR
for (const auto &entry : this->text_sensors_) {
LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor);
ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
for (const auto &item : this->text_sensors_) {
LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", item.sensor);
const auto obis = item.obis.to_string(obis_buf);
ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast<int>(obis.size()), obis.data());
}
#endif
#ifdef USE_BINARY_SENSOR
for (const auto &entry : this->binary_sensors_) {
LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor);
ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
for (const auto &item : this->binary_sensors_) {
LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", item.sensor);
const auto obis = item.obis.to_string(obis_buf);
ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast<int>(obis.size()), obis.data());
}
#endif
}
@@ -166,71 +157,73 @@ void DlmsMeterComponent::read_rx_buffer_() {
void DlmsMeterComponent::process_frame_() {
ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_);
auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) {
this->on_data_(obis_code, float_val, str_val, is_numeric);
};
this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback);
this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_});
this->bytes_accumulated_ = 0;
}
void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) {
void DlmsMeterComponent::on_data_(const dlms_parser::AxdrCapture &capture) {
int updated_count = 0;
if (capture.is_numeric()) {
#if defined(USE_SENSOR) || defined(USE_BINARY_SENSOR)
const float value = capture.value_as_float_with_scaler_applied();
#endif
#ifdef USE_SENSOR
if (is_numeric) {
for (auto &item : this->sensors_) {
if (item.obis_code == obis_code) {
item.sensor->publish_state(float_val);
for (const auto &item : this->sensors_) {
if (item.obis == capture.obis) {
item.sensor->publish_state(value);
updated_count++;
}
}
}
#endif
#ifdef USE_TEXT_SENSOR
if (!is_numeric && str_val != nullptr) {
for (auto &item : this->text_sensors_) {
if (item.obis_code == obis_code) {
item.sensor->publish_state(str_val);
updated_count++;
}
}
}
#endif
#ifdef USE_BINARY_SENSOR
if (is_numeric) {
bool state = float_val != 0.0f;
for (auto &item : this->binary_sensors_) {
if (item.obis_code == obis_code) {
item.sensor->publish_state(state);
for (const auto &item : this->binary_sensors_) {
if (item.obis == capture.obis) {
item.sensor->publish_state(value != 0.0f);
updated_count++;
}
}
}
#endif
} else {
#ifdef USE_TEXT_SENSOR
for (const auto &item : this->text_sensors_) {
if (item.obis == capture.obis) {
std::array<char, 128> value_buf;
const auto value = capture.value_as_string(value_buf);
item.sensor->publish_state(value.data(), value.size());
updated_count++;
}
}
#endif
}
if (updated_count == 0) {
ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code);
std::array<char, 24> obis_buf;
const auto obis = capture.obis.to_string(obis_buf);
ESP_LOGV(TAG, "Received OBIS %.*s, but no sensors are registered for it.", static_cast<int>(obis.size()),
obis.data());
}
}
#ifdef USE_SENSOR
void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) {
this->sensors_.push_back({obis_code, sensor});
void DlmsMeterComponent::register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor) {
this->sensors_.push_back({obis, sensor});
}
#endif
#ifdef USE_TEXT_SENSOR
void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) {
this->text_sensors_.push_back({obis_code, sensor});
void DlmsMeterComponent::register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor) {
this->text_sensors_.push_back({obis, sensor});
}
#endif
#ifdef USE_BINARY_SENSOR
void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) {
this->binary_sensors_.push_back({obis_code, sensor});
void DlmsMeterComponent::register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor) {
this->binary_sensors_.push_back({obis, sensor});
}
#endif
} // namespace esphome::dlms_meter
#endif
+33 -48
View File
@@ -1,9 +1,13 @@
#pragma once
// Ignore Zephyr. It doesn't have any encryption library.
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
#include <array>
#include <vector>
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include "esphome/components/uart/uart.h"
@@ -19,12 +23,6 @@
#include <dlms_parser/dlms_parser.h>
#include <vector>
#include <string>
#include <array>
#include <optional>
#include <span>
#if __has_include(<psa/crypto.h>)
#include <dlms_parser/decryption/aes_128_gcm_decryptor_tfpsa.h>
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
@@ -35,9 +33,12 @@
#elif __has_include(<bearssl/bearssl.h>)
#include <dlms_parser/decryption/aes_128_gcm_decryptor_bearssl.h>
#else
#define DLMS_METER_NO_CRYPTO
#error "The platform doesn't provide a compatible encryption library for dlms_parser"
#endif
namespace esphome::dlms_meter {
// Entity storage is sized by codegen; a platform with no entities emits no define
#ifndef DLMS_MAX_SENSORS
static constexpr uint8_t DLMS_MAX_SENSORS = 0;
#endif
@@ -48,82 +49,64 @@ static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0;
static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0;
#endif
namespace esphome::dlms_meter {
#ifdef DLMS_METER_NO_CRYPTO
// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy)
class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor {
public:
void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {}
bool decrypt_in_place(std::span<const uint8_t> iv, std::span<uint8_t> ciphertext_and_plaintext,
std::span<const uint8_t> aad, std::span<const uint8_t> tag) override {
return false;
}
};
#endif
#if __has_include(<psa/crypto.h>)
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa;
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls;
#elif __has_include(<bearssl/bearssl.h>)
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl;
#else
using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy;
#endif
#ifdef USE_SENSOR
struct SensorItem {
std::string obis_code;
dlms_parser::ObisId obis;
sensor::Sensor *sensor;
};
#endif
#ifdef USE_TEXT_SENSOR
struct TextSensorItem {
std::string obis_code;
dlms_parser::ObisId obis;
text_sensor::TextSensor *sensor;
};
#endif
#ifdef USE_BINARY_SENSOR
struct BinarySensorItem {
std::string obis_code;
dlms_parser::ObisId obis;
binary_sensor::BinarySensor *sensor;
};
#endif
struct CustomPattern {
std::string pattern;
std::optional<std::string> name;
int priority{0};
std::optional<std::array<uint8_t, 6>> default_obis;
const char *pattern;
const char *name;
int priority;
dlms_parser::ObisId default_obis;
};
#if __has_include(<psa/crypto.h>)
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa;
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls;
#else
using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl;
#endif
class DlmsMeterComponent final : public Component, public uart::UARTDevice {
public:
DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check,
std::optional<std::array<uint8_t, 16>> decryption_key,
std::optional<std::array<uint8_t, 16>> authentication_key,
std::vector<CustomPattern> custom_patterns);
DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, const char *decryption_key,
const char *authentication_key, std::vector<CustomPattern> custom_patterns);
void setup() override;
void dump_config() override;
void loop() override;
#ifdef USE_SENSOR
void register_sensor(const std::string &obis_code, sensor::Sensor *sensor);
void register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor);
#endif
#ifdef USE_TEXT_SENSOR
void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor);
void register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor);
#endif
#ifdef USE_BINARY_SENSOR
void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor);
void register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor);
#endif
protected:
void read_rx_buffer_();
void flush_rx_buffer_();
void process_frame_();
void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric);
void on_data_(const dlms_parser::AxdrCapture &capture);
std::array<uint8_t, 2048> rx_buffer_;
size_t bytes_accumulated_{0};
@@ -149,3 +132,5 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice {
};
} // namespace esphome::dlms_meter
#endif
+47 -141
View File
@@ -1,159 +1,65 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_VOLT,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
_LOGGER = logging.getLogger(__name__)
from .. import (
CONF_DLMS_METER_ID,
CONF_OBIS_CODE,
DlmsMeterComponent,
obis_string_to_byte_list,
register_sensor,
)
DEPENDENCIES = ["dlms_meter"]
NUMERIC_KEYS = {
"voltage_l1": "1.0.32.7.0.255",
"voltage_l2": "1.0.52.7.0.255",
"voltage_l3": "1.0.72.7.0.255",
"current_l1": "1.0.31.7.0.255",
"current_l2": "1.0.51.7.0.255",
"current_l3": "1.0.71.7.0.255",
"active_power_plus": "1.0.1.7.0.255",
"active_power_minus": "1.0.2.7.0.255",
"active_energy_plus": "1.0.1.8.0.255",
"active_energy_minus": "1.0.2.8.0.255",
"reactive_energy_plus": "1.0.3.8.0.255",
"reactive_energy_minus": "1.0.4.8.0.255",
"power_factor": "1.0.13.7.0.255",
# Removed in 2026.11.0 - kept to provide helpful error message
# Remove before 2027.5.0
_MEASUREMENT = "state_class: measurement"
_VOLTAGE = f"unit_of_measurement: V, accuracy_decimals: 1, device_class: voltage, {_MEASUREMENT}"
_CURRENT = f"unit_of_measurement: A, accuracy_decimals: 2, device_class: current, {_MEASUREMENT}"
_POWER = (
f"unit_of_measurement: W, accuracy_decimals: 0, device_class: power, {_MEASUREMENT}"
)
_ENERGY = (
"unit_of_measurement: Wh, accuracy_decimals: 0, device_class: energy, "
"state_class: total_increasing"
)
_POWER_FACTOR = f"accuracy_decimals: 3, device_class: power_factor, {_MEASUREMENT}"
REMOVED_KEYS = {
"voltage_l1": ("1.0.32.7.0.255", _VOLTAGE),
"voltage_l2": ("1.0.52.7.0.255", _VOLTAGE),
"voltage_l3": ("1.0.72.7.0.255", _VOLTAGE),
"current_l1": ("1.0.31.7.0.255", _CURRENT),
"current_l2": ("1.0.51.7.0.255", _CURRENT),
"current_l3": ("1.0.71.7.0.255", _CURRENT),
"active_power_plus": ("1.0.1.7.0.255", _POWER),
"active_power_minus": ("1.0.2.7.0.255", _POWER),
"active_energy_plus": ("1.0.1.8.0.255", _ENERGY),
"active_energy_minus": ("1.0.2.8.0.255", _ENERGY),
"reactive_energy_plus": ("1.0.3.8.0.255", _ENERGY),
"reactive_energy_minus": ("1.0.4.8.0.255", _ENERGY),
"power_factor": ("1.0.13.7.0.255", _POWER_FACTOR),
}
DYNAMIC_SCHEMA = sensor.sensor_schema().extend(
CONFIG_SCHEMA = sensor.sensor_schema().extend(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
cv.Required(CONF_OBIS_CODE): obis_code,
cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list,
**{
cv.Optional(key): cv.invalid(
f"The predefined '{key}' key was removed in ESPHome 2026.11.0. "
f"Add a separate '- platform: dlms_meter' sensor with "
f'obis_code: "{obis}" and {settings}'
)
for key, (obis, settings) in REMOVED_KEYS.items()
},
}
)
def deprecation_warning(config: ConfigType) -> ConfigType:
_LOGGER.warning(
"The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. "
"Please update your configuration to use the new schema with 'obis_code'."
)
return config
OLD_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
cv.Optional("voltage_l1"): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("voltage_l2"): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("voltage_l3"): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("current_l1"): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("current_l2"): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("current_l3"): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("active_power_plus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("active_power_minus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("active_energy_plus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("active_energy_minus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_plus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_minus"): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("power_factor"): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend(cv.COMPONENT_SCHEMA),
deprecation_warning,
)
CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
if obis := config.get(CONF_OBIS_CODE):
var = await sensor.new_sensor(config)
cg.add(hub.register_sensor(obis, var))
else:
for key, obis_val in NUMERIC_KEYS.items():
if sensor_config := config.get(key):
sens = await sensor.new_sensor(sensor_config)
cg.add(hub.register_sensor(obis_val, sens))
var = await sensor.new_sensor(config)
register_sensor(hub, config[CONF_OBIS_CODE], var)
@@ -1,60 +1,43 @@
import logging
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
_LOGGER = logging.getLogger(__name__)
from .. import (
CONF_DLMS_METER_ID,
CONF_OBIS_CODE,
DlmsMeterComponent,
obis_string_to_byte_list,
register_text_sensor,
)
DEPENDENCIES = ["dlms_meter"]
TEXT_KEYS = {
# Removed in 2026.11.0 - kept to provide helpful error message
# Remove before 2027.5.0
REMOVED_KEYS = {
"timestamp": "0.0.1.0.0.255",
"meternumber": "0.0.96.1.0.255",
}
DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend(
CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
cv.Required(CONF_OBIS_CODE): obis_code,
cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list,
**{
cv.Optional(key): cv.invalid(
f"The predefined '{key}' key was removed in ESPHome 2026.11.0. "
f"Add a separate '- platform: dlms_meter' text sensor with "
f'obis_code: "{obis}" instead'
)
for key, obis in REMOVED_KEYS.items()
},
}
)
def deprecation_warning(config: ConfigType) -> ConfigType:
_LOGGER.warning(
"The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. "
"Please update your configuration to use the new schema with 'obis_code'."
)
return config
OLD_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
cv.Optional("meternumber"): text_sensor.text_sensor_schema(),
}
).extend(cv.COMPONENT_SCHEMA),
deprecation_warning,
)
CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
if obis := config.get(CONF_OBIS_CODE):
var = await text_sensor.new_text_sensor(config)
cg.add(hub.register_text_sensor(obis, var))
else:
for key, obis_val in TEXT_KEYS.items():
if text_sensor_config := config.get(key):
sens = await text_sensor.new_text_sensor(text_sensor_config)
cg.add(hub.register_text_sensor(obis_val, sens))
var = await text_sensor.new_text_sensor(config)
register_text_sensor(hub, config[CONF_OBIS_CODE], var)
+1 -1
View File
@@ -87,7 +87,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
cg.add_library("esphome/dsmr_parser", "1.9.0")
cg.add_library("esphome/dsmr_parser", "1.10.0")
def final_validate(config: ConfigType) -> None:
+168
View File
@@ -97,6 +97,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -109,6 +115,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -121,6 +133,77 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
# Reactive energy totals and per-quadrant (Q1-Q4) values (Switzerland, e.g. EWL Luzern AM550)
cv.Optional("total_imported_energy_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("total_exported_energy_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -231,6 +314,91 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
# Per-phase active power and maximum demand (Switzerland, e.g. EWL Luzern AM550)
cv.Optional("power_delivered_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_delivered_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_delivered_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("maximum_power_delivered_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("maximum_power_returned_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
+12 -6
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components import socket
from esphome.components.light.effects import register_addressable_effect
from esphome.components.light.types import AddressableLightEffect
import esphome.config_validation as cv
@@ -28,12 +29,17 @@ CHANNELS = {
CONF_UNIVERSE = "universe"
CONF_E131_ID = "e131_id"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(E131Component),
cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True),
}
).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(E131Component),
cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(
*METHODS, upper=True
),
}
).extend(cv.COMPONENT_SCHEMA),
socket.consume_sockets(1, "e131", socket.SocketType.UDP),
)
async def to_code(config: ConfigType) -> None:
+10 -14
View File
@@ -1,6 +1,7 @@
#include "e131.h"
#ifdef USE_NETWORK
#include "e131_addressable_light_effect.h"
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -13,18 +14,18 @@ static const int PORT = 5568;
E131Component::E131Component() {}
E131Component::~E131Component() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
if (this->socket_) {
this->socket_->close();
}
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
this->udp_.stop();
#endif
}
void E131Component::setup() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP);
this->socket_ = socket::socket_ip_udp(IPPROTO_IP);
if (!this->socket_) {
ESP_LOGW(TAG, "Could not create socket: errno %d", errno);
this->mark_failed();
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
@@ -54,18 +55,13 @@ void E131Component::setup() {
this->mark_failed();
return;
}
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
if (!this->udp_.begin(PORT)) {
ESP_LOGW(TAG, "Cannot bind E1.31 to port %d.", PORT);
this->mark_failed();
return;
}
#endif
join_igmp_groups_();
}
void E131Component::loop() {
if (this->igmp_join_pending_ && network::is_connected())
this->join_igmp_groups_();
E131Packet packet;
int universe = 0;
uint8_t buf[1460];
@@ -74,7 +70,7 @@ void E131Component::loop() {
// Drain all queued packets so multi-universe frames are applied
// atomically before the light writes. Without this, each universe
// packet would trigger a separate full-strip write causing tearing.
while ((len = this->read_(buf, sizeof(buf))) > 0) {
while ((len = this->socket_->read(buf, sizeof(buf))) > 0) {
if (!this->packet_(buf, (size_t) len, universe, packet)) {
ESP_LOGV(TAG, "Invalid packet received of size %d.", (int) len);
continue;
+4 -21
View File
@@ -1,11 +1,7 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NETWORK
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
#include "esphome/components/socket/socket.h"
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
#include <WiFiUdp.h>
#endif
#include "esphome/core/component.h"
#include <cinttypes>
@@ -45,30 +41,17 @@ class E131Component final : public esphome::Component {
void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; }
protected:
inline ssize_t read_(uint8_t *buf, size_t len) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
return this->socket_->read(buf, len);
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
if (!this->udp_.parsePacket())
return -1;
return this->udp_.read(buf, len);
#else
return -1;
#endif
}
bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet);
bool process_(int universe, const E131Packet &packet);
bool join_igmp_groups_();
void join_igmp_groups_();
void join_universe_(uint16_t universe);
UniverseConsumer *find_universe_(int universe);
void join_(int universe);
void leave_(int universe);
E131ListenMethod listen_method_{E131_MULTICAST};
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
std::unique_ptr<socket::Socket> socket_;
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
WiFiUDP udp_;
#endif
bool igmp_join_pending_{false};
std::unique_ptr<socket::UDPSocket> socket_;
std::vector<E131AddressableLightEffect *> light_effects_;
std::vector<UniverseConsumer> universe_consumers_;
};
+33 -24
View File
@@ -3,6 +3,7 @@
#include "e131.h"
#ifdef USE_NETWORK
#include "esphome/components/network/ip_address.h"
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#include "esphome/core/helpers.h"
@@ -59,33 +60,35 @@ union E131RawPacket {
// Get the offset of `property_values[1]`
const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t);
bool E131Component::join_igmp_groups_() {
if (this->listen_method_ != E131_MULTICAST)
return false;
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
if (this->socket_ == nullptr)
return false;
#endif
void E131Component::join_igmp_groups_() {
if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr)
return;
// Joined from loop() once the network is up; RP2040 has no interface to join before then
if (!network::is_connected()) {
this->igmp_join_pending_ = true;
return;
}
this->igmp_join_pending_ = false;
for (auto &entry : this->universe_consumers_) {
if (!entry.consumers)
continue;
if (entry.consumers)
this->join_universe_(entry.universe);
}
}
ip4_addr_t multicast_addr =
network::IPAddress(239, 255, ((entry.universe >> 8) & 0xff), ((entry.universe >> 0) & 0xff));
void E131Component::join_universe_(uint16_t universe) {
ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff));
err_t err;
{
LwIPLock lock;
err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
}
if (err) {
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", entry.universe);
}
err_t err;
{
LwIPLock lock;
err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
}
return true;
if (err) {
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe);
} else {
ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe);
}
}
UniverseConsumer *E131Component::find_universe_(int universe) {
@@ -107,9 +110,15 @@ void E131Component::join_(int universe) {
this->universe_consumers_.push_back({static_cast<uint16_t>(universe), 1});
}
if (this->join_igmp_groups_()) {
ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe);
if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr)
return;
// A pending batch join covers this universe too; joining it here as well would break leave_()
if (this->igmp_join_pending_ || !network::is_connected()) {
ESP_LOGD(TAG, "IGMP join for %d universe of E1.31 deferred until the network is up.", universe);
this->igmp_join_pending_ = true;
return;
}
this->join_universe_(universe);
}
void E131Component::leave_(int universe) {
+29 -4
View File
@@ -27,6 +27,7 @@ EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice)
CONF_EMONTX_ID = "emontx_id"
CONF_TAG_NAME = "tag_name"
CONF_ON_JSON = "on_json"
CONF_LOCK_ON_REBOOT = "lock_on_reboot"
DOMAIN = "emontx"
@@ -44,13 +45,21 @@ def _get_data() -> EmonTxData:
return CORE.data[DOMAIN]
def _synchronous_automation_schema() -> cv.All:
# The received line and the parsed JSON document only live while the callbacks run.
return automation.validate_synchronous_automation(
"on_json and on_data", "the received data"
)
# Main configuration schema
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(EmonTx),
cv.Optional(CONF_ON_JSON): automation.validate_automation({}),
cv.Optional(CONF_ON_DATA): automation.validate_automation({}),
cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(),
cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(),
cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -104,10 +113,10 @@ _CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_JSON,
"add_on_json_callback",
[(cg.JsonObject, "json"), (cg.std_string, "raw_json")],
[(cg.JsonObject, "json"), (cg.StringRef, "raw_json")],
),
automation.CallbackAutomation(
CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")]
CONF_ON_DATA, "add_on_data_callback", [(cg.StringRef, "data")]
),
)
@@ -126,6 +135,9 @@ async def to_code(config: ConfigType) -> None:
await uart.register_uart_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config[CONF_LOCK_ON_REBOOT]:
cg.add(var.set_lock_on_reboot(True))
# Action: emontx.send_command
@@ -150,3 +162,16 @@ automation.register_apply_action(
CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal
),
)
# Let another component (e.g. serial_proxy) take exclusive ownership of the UART.
EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)})
automation.register_apply_action(
"emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)")
)
automation.register_apply_action(
"emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)")
)
automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()")
+51 -7
View File
@@ -6,7 +6,27 @@ namespace esphome::emontx {
ESPHOME_LOG_TAG(TAG, "emontx");
void EmonTx::setup() { this->buffer_pos_ = 0; }
void EmonTx::setup() {
this->buffer_pos_ = 0;
if (!this->lock_on_reboot_)
return;
// The line feed ends any boot noise first
ESP_LOGI(TAG, "Unlocking emonTx after boot");
this->write_byte('\n');
this->send_command("emonunlock");
}
void EmonTx::on_shutdown() {
if (!this->lock_on_reboot_)
return;
if (this->is_paused()) {
ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused");
return;
}
ESP_LOGI(TAG, "Locking emonTx before shutdown");
this->send_command("emonlock");
this->flush();
}
/**
* @brief Implements the main loop for parsing data from the serial port.
@@ -16,15 +36,31 @@ void EmonTx::setup() { this->buffer_pos_ = 0; }
* 2. If line starts with '{', parse as JSON and update sensors/callbacks
*/
void EmonTx::loop() {
if (this->is_paused()) {
this->was_paused_ = true;
return;
}
if (this->was_paused_) {
// Drop what arrived during the pause and resync at the next line start
this->was_paused_ = false;
this->buffer_pos_ = 0;
this->discarding_line_ = true;
uint8_t dropped;
while (this->available() > 0 && this->read_byte(&dropped))
this->discarding_line_ = dropped != '\n';
}
// Read all available data to prevent UART buffer overflow
while (this->available() > 0) {
uint8_t received = this->read();
uint8_t received;
if (!this->read_byte(&received))
break;
if (received == '\r') {
continue; // Ignore CR
} else if (received == '\n') {
if (received == '\n') {
// End of line - process the buffer
if (this->buffer_pos_ > 0) {
if (this->discarding_line_) {
this->discarding_line_ = false;
} else if (this->buffer_pos_ > 0) {
// Null-terminate for safe logging and c_str() use
size_t len = this->buffer_pos_;
this->buffer_[len] = '\0';
@@ -42,9 +78,12 @@ void EmonTx::loop() {
this->parse_json_(this->buffer_.data(), len);
}
}
} else if (received == '\r' || this->discarding_line_) {
// Ignore CR, and the rest of a line that overflowed or was interrupted by a pause
} else if (this->buffer_pos_ >= MAX_LINE_LENGTH) {
ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH);
ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH);
this->buffer_pos_ = 0;
this->discarding_line_ = true;
} else {
this->buffer_[this->buffer_pos_++] = static_cast<char>(received);
}
@@ -78,6 +117,7 @@ void EmonTx::parse_json_(const char *data, size_t len) {
*/
void EmonTx::dump_config() {
ESP_LOGCONFIG(TAG, "EmonTx:");
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_));
#ifdef USE_SENSOR
ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size());
@@ -95,6 +135,10 @@ void EmonTx::dump_config() {
* @param command The command string to send (LF will be appended automatically).
*/
void EmonTx::send_command(const char *command) {
if (this->is_paused()) {
ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command);
return;
}
ESP_LOGD(TAG, "Sending command to emonTx: %s", command);
this->write_str(command);
this->write_byte('\n');
+16
View File
@@ -8,6 +8,7 @@
#include "esphome/components/json/json_util.h"
#include <array>
#include <atomic>
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
@@ -32,6 +33,7 @@ class EmonTx final : public Component, public uart::UARTDevice {
void loop() override;
void setup() override;
void dump_config() override;
void on_shutdown() override;
template<typename F> void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward<F>(callback)); }
@@ -41,6 +43,16 @@ class EmonTx final : public Component, public uart::UARTDevice {
void send_command(const char *command);
void send_command(const std::string &command) { this->send_command(command.c_str()); }
/// Stop reading the UART and sending commands, so another component can use it.
/// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass.
void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); }
bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); }
/// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken
/// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later.
void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; }
#ifdef USE_SENSOR
void init_sensors(size_t count) { this->sensors_.init(count); }
void register_sensor(const char *tag_name, sensor::Sensor *sensor);
@@ -55,6 +67,10 @@ class EmonTx final : public Component, public uart::UARTDevice {
LazyCallbackManager<void(JsonObject, StringRef)> json_callbacks_;
LazyCallbackManager<void(StringRef)> data_callbacks_;
uint16_t buffer_pos_{0};
std::atomic<bool> paused_{false};
bool was_paused_{false};
bool lock_on_reboot_{false};
bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
};
+1 -1
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPENSATION,
@@ -26,7 +27,6 @@ DOMAIN = "ens160_base"
ens160_ns = cg.esphome_ns.namespace("ens160_base")
CONF_AQI = "aqi"
CONFIG_SCHEMA_BASE = cv.Schema(
{
@@ -118,3 +118,39 @@ jd79660.extend(
(0x04,),
),
)
# Good Display GDEY0213F51
#
# Panel: 2.13" BWYR (4-color) e-paper, 122x250, controller JD79661
# Init derived from Good Display sample
# <https://www.good-display.com/companyfile/1047.html>
# Cross-checked against GxEPD2_213c_GDEY0213F51
# <https://github.com/ZinggJM/GxEPD2/blob/master/src/epd4c/GxEPD2_213c_GDEY0213F51.cpp>
#
# Note: busy pin uses LOW=busy, HIGH=idle.
#
# fmt: off
jd79660.extend(
"goodisplay-gdey0213f51-2.13",
width=128,
height=250,
initsequence=(
(0x4D, 0x78,),
(0x00, 0x0F, 0x29,), # PSR
(0x01, 0x07, 0x00,), # PWRR
(0x03, 0x10, 0x54, 0x44,), # POFS
(0x06, 0x05, 0x00, 0x3F, 0x0A, 0x25, 0x12, 0x1A,), # BTST_P
(0x50, 0x37,), # CDI
(0x60, 0x02, 0x02,), # TCON
(0x61, 128 // 256, 128 % 256, 250 // 256, 250 % 256,), # TRES: 128x250 fixed
(0xE7, 0x1C,),
(0xE3, 0x22,),
(0xB4, 0xD0,),
(0xB5, 0x03,),
(0xE9, 0x01,),
(0x30, 0x08,),
# Power On (0x04): Must be early part of init seq = Disabled later!
(0x04,),
),
)
+2 -2
View File
@@ -231,7 +231,7 @@ bool ES8388::set_dac_output(DacOutputLine line) {
optional<DacOutputLine> ES8388::get_dac_power() {
uint8_t dac_power;
if (!this->read_byte(ES8388_DACPOWER, &dac_power)) {
this->status_momentary_warning("dacpower_read");
this->status_momentary_warning();
return {};
}
switch (dac_power) {
@@ -274,7 +274,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) {
optional<AdcInputMicLine> ES8388::get_mic_input() {
uint8_t mic_input;
if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) {
this->status_momentary_warning("adccontrol2_read");
this->status_momentary_warning();
return {};
}
switch (mic_input) {
+103 -11
View File
@@ -15,7 +15,6 @@ from esphome.components.const import (
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION,
CONF_IGNORE_NOT_FOUND,
)
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -552,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS)
FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values()))
_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ"
_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_"
# Older IDF versions named the CPU frequency options after the chip
_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset(
{VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3}
)
def _get_sdkconfig_cpu_frequencies(
sdkconfig_options: dict[str, str], variant: str
) -> tuple[set[int], set[int]]:
"""Return the CPU frequencies in MHz that sdkconfig_options selects and disables."""
# (integer option, choice option prefix) pairs that can carry a frequency
keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)]
if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS:
keys.append(
(
f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ",
f"CONFIG_{variant}_DEFAULT_CPU_FREQ_",
)
)
selected: set[int] = set()
disabled: set[int] = set()
for integer_key, choice_prefix in keys:
if (value := sdkconfig_options.get(integer_key)) is not None:
try:
selected.add(int(value))
except ValueError as err:
raise cv.Invalid(
f"{integer_key} must be an integer MHz value",
path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS],
) from err
for name, value in sdkconfig_options.items():
mhz = name.removeprefix(choice_prefix)
if mhz == name or not mhz.isdigit():
continue
if value.lower() == "y":
selected.add(int(mhz))
elif value.lower() == "n":
disabled.add(int(mhz))
else:
raise cv.Invalid(
f"{name} must be y or n",
path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS],
)
return selected, disabled
def set_core_data(config):
cpu_frequency = config.get(CONF_CPU_FREQUENCY, None)
cpu_frequency = config.get(CONF_CPU_FREQUENCY)
variant = config[CONF_VARIANT]
sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS]
selected, disabled = _get_sdkconfig_cpu_frequencies(
config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant
)
if len(selected) > 1:
conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected))
raise cv.Invalid(
f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})",
path=sdkconfig_path,
)
sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None
if cpu_frequency is None and sdkconfig_cpu_frequency is not None:
if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]:
raise cv.Invalid(
f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support",
path=sdkconfig_path,
)
_LOGGER.warning(
"sdkconfig_options contains a CPU frequency setting; using %s. "
"Set 'esp32.cpu_frequency' to configure it directly.",
sdkconfig_cpu_frequency,
)
cpu_frequency = sdkconfig_cpu_frequency
elif (
sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency
):
raise cv.Invalid(
f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options "
f"({sdkconfig_cpu_frequency})",
path=sdkconfig_path,
)
# if not specified in config, default to the maximum supported frequency
# (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz)
if cpu_frequency is None:
@@ -563,12 +641,17 @@ def set_core_data(config):
cpu_frequency = "360MHZ"
else:
cpu_frequency = choices[-1]
config[CONF_CPU_FREQUENCY] = cpu_frequency
elif cpu_frequency not in CPU_FREQUENCIES[variant]:
raise cv.Invalid(
f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}",
path=[CONF_CPU_FREQUENCY],
)
if int(cpu_frequency[:-3]) in disabled:
raise cv.Invalid(
f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})",
path=sdkconfig_path,
)
config[CONF_CPU_FREQUENCY] = cpu_frequency
if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ":
_LOGGER.warning(
@@ -1988,6 +2071,17 @@ def require_vfs_termios() -> None:
CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True
def allow_insecure_tls() -> None:
"""Let esp-tls connect without verifying the server certificate.
The two sdkconfig flags are global, so a component asking for an unverified
connection switches them on for the whole build; they are never switched off
here because another component may have asked first.
"""
add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True)
add_idf_sdkconfig_option("CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True)
def require_certificate_bundle() -> None:
"""Enable the mbedTLS root certificate bundle for this build.
@@ -2926,6 +3020,11 @@ async def to_code(config):
# produce reproducible outputs and downstream tooling can reuse artifacts.
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
# --undefined: libsrc.a is scanned before the IDF libraries that also register them.
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit")
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-DUSE_ESP_IDF")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
@@ -3134,7 +3233,7 @@ async def to_code(config):
# Set default CPU frequency
add_idf_sdkconfig_option(
f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True
f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True
)
# Apply LWIP optimization settings
@@ -3962,10 +4061,3 @@ def process_stacktrace(config, line, backtrace_state):
_decode_pc(config, addr.group())
return backtrace_state
# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which
# are instantiated solely by the pin schema codegen (esp32_pin_to_code)
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"}
)
+20
View File
@@ -0,0 +1,20 @@
/*
* Linker wrap stub for __cxa_atexit().
*
* GCC registers the destructor of every global and function local static
* object with __cxa_atexit(). The firmware never returns from main or calls
* exit(), so these destructors never run, but the first registration links
* newlib's exit handler tables (about 400 bytes of RAM on ESP32).
*/
#include "esphome/core/defines.h"
#ifdef USE_ESP32
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP32
+84 -5
View File
@@ -1,9 +1,10 @@
#include "esphome/core/defines.h"
// Also defines the core ISRInternalGPIOPin methods; those are only reachable
// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely.
#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO)
// Always built: InternalGPIOPin is this class, so components that merely hold a
// pointer to it reference these methods even in configs without a pin.
#ifdef USE_ESP32
#include "gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "driver/gpio.h"
#include "driver/rtc_io.h"
@@ -49,17 +50,52 @@ struct ISRPinArg {
gpio_num_t pin;
gpio::Flags flags;
bool inverted;
#ifdef USE_GPIO_HOLD
bool hold;
#endif
#if defined(USE_ESP32_VARIANT_ESP32)
bool use_rtc;
int rtc_pin;
#endif
};
#ifdef USE_GPIO_HOLD
// Re-latch a held pad onto the values just written to its registers.
static inline void refresh_hold(const ESP32InternalGPIOPin &pin) {
pin.set_hold(false);
pin.set_hold(true);
}
static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) {
if (!arg->hold)
return;
#if defined(USE_ESP32_VARIANT_ESP32)
if (arg->use_rtc) {
rtcio_hal_hold_disable(arg->rtc_pin);
rtcio_hal_hold_enable(arg->rtc_pin);
return;
}
#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
// GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them
if (arg->pin < SOC_RTCIO_PIN_COUNT) {
rtcio_hal_hold_disable(arg->pin);
rtcio_hal_hold_enable(arg->pin);
return;
}
#endif
gpio_hal_hold_dis(&GPIO_HAL, arg->pin);
gpio_hal_hold_en(&GPIO_HAL, arg->pin);
}
#endif
ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory)
arg->pin = this->get_pin_num();
arg->flags = gpio::FLAG_NONE;
arg->inverted = this->pin_flags_.inverted;
#ifdef USE_GPIO_HOLD
arg->hold = this->get_hold();
#endif
#if defined(USE_ESP32_VARIANT_ESP32)
arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num());
if (arg->use_rtc)
@@ -68,7 +104,7 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
return ISRInternalGPIOPin((void *) arg);
}
void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const {
void ESP32InternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const {
gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE;
switch (type) {
case gpio::INTERRUPT_RISING_EDGE:
@@ -104,7 +140,23 @@ size_t ESP32InternalGPIOPin::dump_summary(char *buffer, size_t len) const {
return snprintf(buffer, len, "GPIO%" PRIu32, static_cast<uint32_t>(this->pin_));
}
#ifdef USE_GPIO_HOLD
void ESP32InternalGPIOPin::set_hold(bool hold) const {
if (!this->get_hold())
return;
if (hold) {
gpio_hold_en(this->get_pin_num());
} else {
gpio_hold_dis(this->get_pin_num());
}
}
#endif
void ESP32InternalGPIOPin::setup() {
#ifdef USE_GPIO_HOLD
// Hold before gpio_config so the pad keeps its state while the registers change
this->set_hold(true);
#endif
gpio_config_t conf{};
conf.pin_bit_mask = 1ULL << static_cast<uint32_t>(this->pin_);
conf.mode = flags_to_mode(this->flags_);
@@ -115,6 +167,18 @@ void ESP32InternalGPIOPin::setup() {
if (this->flags_ & gpio::FLAG_OUTPUT) {
gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength());
}
#ifdef USE_GPIO_HOLD
// gpio_hold_dis logs an error on input-only pads
if (GPIO_IS_VALID_OUTPUT_GPIO(this->get_pin_num())) {
if (!this->get_hold()) {
// Release a hold left behind by an earlier boot
gpio_hold_dis(this->get_pin_num());
} else if (this->flags_ & gpio::FLAG_INPUT) {
// Inputs apply the config now so reads work; outputs wait for the first write
refresh_hold(*this);
}
}
#endif
}
void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) {
@@ -129,6 +193,9 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) {
pull_mode = GPIO_PULLDOWN_ONLY;
}
gpio_set_pull_mode(this->get_pin_num(), pull_mode);
#ifdef USE_GPIO_HOLD
refresh_hold(*this);
#endif
}
bool ESP32InternalGPIOPin::digital_read() {
@@ -136,6 +203,9 @@ bool ESP32InternalGPIOPin::digital_read() {
}
void ESP32InternalGPIOPin::digital_write(bool value) {
gpio_set_level(this->get_pin_num(), value != this->pin_flags_.inverted ? 1 : 0);
#ifdef USE_GPIO_HOLD
refresh_hold(*this);
#endif
}
void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->get_pin_num()); }
@@ -143,6 +213,8 @@ void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->ge
using namespace esp32;
// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- some gpio_hal functions use MMIO at fixed addresses
// internally
bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
auto *arg = reinterpret_cast<ISRPinArg *>(this->arg_);
return bool(gpio_hal_get_level(&GPIO_HAL, arg->pin)) != arg->inverted;
@@ -151,6 +223,9 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) {
auto *arg = reinterpret_cast<ISRPinArg *>(this->arg_);
gpio_hal_set_level(&GPIO_HAL, arg->pin, value != arg->inverted);
#ifdef USE_GPIO_HOLD
isr_refresh_hold(arg);
#endif
}
void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() {
@@ -202,9 +277,13 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
gpio_hal_input_disable(&GPIO_HAL, arg->pin);
}
}
#ifdef USE_GPIO_HOLD
isr_refresh_hold(arg);
#endif
arg->flags = flags;
}
// NOLINTEND(clang-analyzer-core.FixedAddressDereference)
} // namespace esphome
#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO
#endif // USE_ESP32
+22 -7
View File
@@ -1,7 +1,8 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/hal.h"
#include "esphome/core/gpio_pin.h"
#include <esp_system.h>
#include <driver/gpio.h>
namespace esphome::esp32 {
@@ -10,7 +11,7 @@ namespace esphome::esp32 {
static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t");
static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field");
class ESP32InternalGPIOPin final : public InternalGPIOPin {
class ESP32InternalGPIOPin final : public GPIOPin {
public:
void set_pin(gpio_num_t pin) { this->pin_ = static_cast<uint8_t>(pin); }
void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; }
@@ -24,16 +25,30 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin {
bool digital_read() override;
void digital_write(bool value) override;
size_t dump_summary(char *buffer, size_t len) const override;
void detach_interrupt() const override;
ISRInternalGPIOPin to_isr() const override;
uint8_t get_pin() const override { return this->pin_; }
void detach_interrupt() const;
template<typename T> void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const {
this->attach_interrupt_(reinterpret_cast<void (*)(void *)>(func), arg, type);
}
ISRInternalGPIOPin to_isr() const;
uint8_t get_pin() const { return this->pin_; }
gpio::Flags get_flags() const override { return this->flags_; }
bool is_inverted() const override { return this->pin_flags_.inverted; }
bool is_inverted() const { return this->pin_flags_.inverted; }
bool is_internal() override { return true; }
gpio_num_t get_pin_num() const { return static_cast<gpio_num_t>(this->pin_); }
gpio_drive_cap_t get_drive_strength() const { return static_cast<gpio_drive_cap_t>(this->pin_flags_.drive_strength); }
#ifdef USE_GPIO_HOLD
/// Apply or release the pad hold. Does nothing unless the pin is configured with hold_state.
void set_hold(bool hold) const;
bool is_held() const override {
if (!this->get_hold())
return false;
esp_reset_reason_t reason = esp_reset_reason();
return reason == ESP_RST_DEEPSLEEP || reason == ESP_RST_SW;
}
#endif
protected:
void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override;
void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const;
// Memory layout: 8 bytes total on 32-bit systems
// - 3 bytes for members below
+7 -2
View File
@@ -5,6 +5,7 @@ from typing import Any
from esphome import pins
import esphome.codegen as cg
from esphome.components.const import CONF_HOLD_STATE
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -248,6 +249,7 @@ ESP32_PIN_SCHEMA = cv.All(
cv.float_with_unit("current", "mA", optional_unit=True),
cv.enum(DRIVE_STRENGTHS),
),
cv.Optional(CONF_HOLD_STATE, default=False): cv.boolean,
}
),
validate_gpio_pin,
@@ -257,7 +259,6 @@ ESP32_PIN_SCHEMA = cv.All(
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA)
async def esp32_pin_to_code(config):
cg.add_define("USE_ESP32_INTERNAL_GPIO")
var = cg.new_Pvariable(config[CONF_ID])
num = config[CONF_NUMBER]
cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}")))
@@ -267,7 +268,11 @@ async def esp32_pin_to_code(config):
cg.add(var.set_inverted(True))
if CONF_DRIVE_STRENGTH in config:
cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH]))
cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE])))
flags = pins.gpio_flags_expr(config[CONF_MODE])
if config[CONF_HOLD_STATE]:
flags = flags | cg.gpio_Flags.FLAG_HOLD
cg.add_define("USE_GPIO_HOLD")
cg.add(var.set_flags(flags))
return var
+26
View File
@@ -0,0 +1,26 @@
"""Pin checks shared by the ESP32 variant validators."""
from logging import Logger
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
from esphome.const import CONF_NUMBER
def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None:
if num in usb_jtag_pins:
logger.warning(
"GPIO%d is used by the USB-Serial-JTAG interface."
" Using this pin as GPIO will conflict with USB-Serial-JTAG.",
num,
)
def check_usb_jtag_hold(
conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger
) -> None:
num = conf[CONF_NUMBER]
if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins:
logger.warning(
"GPIO%d cannot hold at low level during wakeup from deep sleep.", num
)
+10
View File
@@ -1,6 +1,7 @@
import logging
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
import esphome.config_validation as cv
from esphome.const import (
CONF_INPUT,
@@ -53,6 +54,7 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
is_output = mode[CONF_OUTPUT]
is_pullup = mode[CONF_PULLUP]
is_pulldown = mode[CONF_PULLDOWN]
is_hold = value.get(CONF_HOLD_STATE)
if is_input:
# All ESP32 pins support input mode
@@ -70,6 +72,14 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
raise cv.Invalid(
f"GPIO{num} (34-39) does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN]
)
if is_hold and 34 <= num <= 39:
raise cv.Invalid(
f"GPIO{num} (34-39) is input-only and cannot be held.",
[CONF_HOLD_STATE],
)
if is_hold and num == 20:
# Not in the digital hold mask and not an RTC pad, so the driver cannot hold it
raise cv.Invalid("GPIO20 has no hold function.", [CONF_HOLD_STATE])
check_strapping_pin(value, _ESP32_STRAPPING_PINS, _LOGGER)
return value
+4 -6
View File
@@ -5,6 +5,8 @@ import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
from esphome.pins import check_strapping_pin
from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin
_ESP32C3_SPI_PSRAM_PINS = {
12: "SPIHD",
13: "SPIWP",
@@ -28,12 +30,7 @@ def esp32_c3_validate_gpio_pin(value: int) -> int:
raise cv.Invalid(
f"This pin cannot be used on ESP32-C3s and is already used by the SPI/PSRAM interface (function: {_ESP32C3_SPI_PSRAM_PINS[value]})"
)
if value in _ESP32C3_USB_JTAG_PINS:
_LOGGER.warning(
"GPIO%d is used by the USB-Serial-JTAG interface."
" Using this pin as GPIO will conflict with USB-Serial-JTAG.",
value,
)
check_usb_jtag_pin(value, _ESP32C3_USB_JTAG_PINS, _LOGGER)
return value
@@ -49,6 +46,7 @@ def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
if is_input:
# All ESP32 pins support input mode
pass
check_usb_jtag_hold(value, _ESP32C3_USB_JTAG_PINS, _LOGGER)
check_strapping_pin(value, _ESP32C3_STRAPPING_PINS, _LOGGER)
return value

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