mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
Merge remote-tracking branch 'origin/dev' into store-yaml-helpers-core
# Conflicts: # esphome/yaml_util.py
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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_(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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,),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user