Merge remote-tracking branch 'origin/dev' into rtttl-play-without-copy

This commit is contained in:
J. Nick Koston
2026-10-12 15:08:58 -10:00
3145 changed files with 63663 additions and 11458 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => {
});
const currentLabels = currentLabelsData.map(label => label.name);
const managedLabels = currentLabels.filter(label =>
label.startsWith('component: ') || MANAGED_LABELS.includes(label)
label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label)
);
// Check for mega-PR early - if present, skip most automatic labeling
+26 -10
View File
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
`large side `;
if (tooManyLabels && tooManyChanges) {
message +=
@@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
}
message += `, which makes it harder for maintainers to review.\n\n`;
let condition;
if (tooManyLabels && tooManyChanges) {
condition =
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
`at most ${MAX_LABELS} components/areas`;
} else if (tooManyLabels) {
condition = `touches at most ${MAX_LABELS} components/areas`;
} else {
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
}
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
`**This PR will not be reviewed until it ${condition}.** We know ` +
`that is not what you want to hear after this much work, and we want ` +
`to be upfront so you do not wait for a review that is not coming: ` +
`please split the change. The label is removed automatically once ` +
`the PR ${condition}.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
`There is no exception you can request. Rarely, a maintainer agrees in ` +
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
`but that is rare enough that you should plan on splitting rather than ` +
`wait for it. Smaller, focused PRs are picked up much faster because ` +
`they fit into the short gaps between other maintainer work. For a new ` +
`component, the first PR brings the hub and a single platform (for ` +
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
@@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
}
module.exports = {
generateReviewMessages,
handleReviews,
handleMaintainerAccessComment
};
@@ -0,0 +1,38 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { generateReviewMessages } = require('../reviews');
const MAX_LABELS = 15;
const TOO_BIG_THRESHOLD = 1000;
function tooBigMessage(labelCount, additions) {
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
assert.equal(messages.length, 1);
return messages[0];
}
describe('too-big review message', () => {
it('names the line cap when the PR is too long', () => {
const message = tooBigMessage(3, 1500);
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
assert.doesNotMatch(message, /components\/areas\./);
});
it('names the area cap when the PR touches too many areas but is short', () => {
const message = tooBigMessage(20, 200);
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
assert.doesNotMatch(message, /until it is at most 1000 lines/);
});
it('names both caps when both are exceeded', () => {
const message = tooBigMessage(20, 1500);
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
});
it('tells new-component authors to bring one platform first', () => {
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
assert.match(tooBigMessage(3, 1500), /mega-pr/);
});
});
+111 -23
View File
@@ -286,7 +286,7 @@ jobs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
with:
prek-version: ${{ steps.prek.outputs.version }}
# This job only runs on pull requests, so nothing ever populates
@@ -893,14 +893,20 @@ jobs:
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 1/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
name: Run script/clang-tidy for ESP32 IDF 1/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 2/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
name: Run script/clang-tidy for ESP32 IDF 2/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 3/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
name: Run script/clang-tidy for ESP32 IDF 3/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 4/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 5/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
steps:
- name: Check out code from GitHub
@@ -1329,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
@@ -1416,31 +1507,27 @@ jobs:
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
fi
# Check if test files exist on the target branch for the requested
# components and platform. When a PR adds new test files for a platform,
# the target branch won't have them yet, so skip instead of failing.
# This check must be done here (not in determine-jobs.py) because
# determine-jobs runs on the PR branch and cannot see what the target
# branch has.
# The build below uses --base-only, so skip unless the target branch has
# test.<platform>.yaml for every requested component (test-*.yaml
# variants alone compile nothing, and a partial set would compare a
# smaller target build against the PR build). Checked here, not in
# determine-jobs.py, because determine-jobs runs on the PR branch and
# cannot see the target branch.
- name: Check for test files on target branch
id: check-tests
if: steps.check-script.outputs.skip != 'true'
run: |
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
found=false
missing=""
for component in $(echo "$components" | jq -r '.[]'); do
# Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml)
for f in tests/components/${component}/test*.${platform}.yaml; do
if [ -f "$f" ]; then
found=true
break 2
fi
done
if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then
missing="${missing} ${component}"
fi
done
if [ "$found" = false ]; then
if [ -n "$missing" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis"
echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis"
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
@@ -1703,6 +1790,7 @@ jobs:
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- test-esp8266-native-windows
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
+15 -1
View File
@@ -16,16 +16,18 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-stale-conflicting: 30
days-before-close: 7
stale-label: stale
exempt-label: not-stale
conflict-label: needs-rebase
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
@@ -36,6 +38,18 @@ jobs:
to keep it open. Also, please merge the latest dev branch into your
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
conflict-pr-message: >
This pull request has merge conflicts. Please merge the latest dev
branch into your branch to resolve them.
stale-conflicting-pr-message: >
The merge conflicts on this pull request have not been resolved for
30 days, so it has been marked as stale and will be closed if they
are not resolved within 7 days.
Please merge the latest dev branch into your branch. Once the pull
request is mergeable again the stale label is removed.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
@@ -15,6 +15,8 @@ on:
# base branch checkout, so fork code never runs with the token.
pull_request_target:
types: [opened, synchronize, reopened]
branches:
- dev
paths:
- requirements_dev.txt
- requirements_test.txt
+72
View File
@@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
@@ -177,6 +178,15 @@ 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.
```cpp
static constexpr uint32_t READ_TIMEOUT_ID = 0;
this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); });
```
* **Don't override a default with the same value:** if a base class method already returns what you
want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a
component that wants `DATA` should simply leave it alone.
@@ -184,6 +194,33 @@ 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.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
resend and give-up logic handles it; never advance on an answer that was only logged.
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
listener base (a second vptr per entity).
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
* **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
@@ -230,6 +267,10 @@ file does, and it is the authority when they disagree. The most useful starting
```
* **Component Metadata:**
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
metadata constants. CI (`lint_component_domain`) fails without it.
- `DEPENDENCIES`: List of required components
- `AUTO_LOAD`: Components to automatically load
- `CONFLICTS_WITH`: Incompatible components
@@ -243,6 +284,7 @@ file does, and it is the authority when they disagree. The most useful starting
import esphome.config_validation as cv
from esphome.const import CONF_KEY, CONF_ID
DOMAIN = "my_component"
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
my_component_ns = cg.esphome_ns.namespace("my_component")
@@ -384,6 +426,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 {
@@ -503,6 +564,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`.
@@ -538,6 +600,15 @@ 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`). Its
`responses` match on content every time they see it; to answer a repeated request differently, use an
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
* **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/
@@ -584,6 +655,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
+42 -3
View File
@@ -39,9 +39,12 @@ esphome/components/am43/sensor/* @buxtronix
esphome/components/analog_threshold/* @ianchi
esphome/components/animation/* @syndlex
esphome/components/anova/* @buxtronix
esphome/components/apc1/* @ademuri
esphome/components/apds9306/* @aodrenah
esphome/components/apds9930/* @iyesin
esphome/components/api/* @esphome/core
esphome/components/aqi/* @freekode @jasstrong @ximex
esphome/components/aranet/* @patrickod
esphome/components/as5600/* @ammmze
esphome/components/as5600/sensor/* @ammmze
esphome/components/as7341/* @mrgnr
@@ -65,6 +68,7 @@ esphome/components/bedjet/climate/* @jhansche
esphome/components/bedjet/fan/* @jhansche
esphome/components/bedjet/sensor/* @javawizard @jhansche
esphome/components/beken_spi_led_strip/* @Mat931
esphome/components/bh1745/* @latonita
esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
@@ -100,6 +104,8 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
@@ -156,8 +162,10 @@ esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds1603l/* @JakeLC15
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
@@ -183,11 +191,13 @@ esphome/components/esp32_camera_web_server/* @ayufan
esphome/components/esp32_can/* @Sympatron
esphome/components/esp32_hosted/* @swoboda1337
esphome/components/esp32_hosted/update/* @swoboda1337
esphome/components/esp32_power_management/* @rwrozelle
esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
esphome/components/esp_ldo/* @clydebarrow
esphome/components/espectre/* @francescopace
esphome/components/esphome/ota/* @esphome/core
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
@@ -196,6 +206,7 @@ esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
@@ -263,6 +274,7 @@ esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/husb238/* @latonita
esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
@@ -270,6 +282,7 @@ esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/i2s_clockless_led_strip/* @j9brown
esphome/components/iaqcore/* @yozik04
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
@@ -287,6 +300,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci
esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_base/* @bdraco @kbx81
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
@@ -298,9 +312,11 @@ esphome/components/kuntze/* @ssieb
esphome/components/lc709203f/* @ilikecake
esphome/components/lcd_menu/* @numo68
esphome/components/ld2410/* @regevbr @sebcaps
esphome/components/ld2410s/* @NovakIrs
esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld2460/* @jesusvallejo
esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
@@ -309,6 +325,10 @@ esphome/components/libretiny_pwm/* @kuba2k2
esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lis2dw12/* @latonita
esphome/components/lis2dw12_i2c/* @latonita
esphome/components/lis2dw12_spi/* @latonita
esphome/components/lis3dh/* @zebble
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
@@ -316,6 +336,7 @@ esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lp5562/* @DennisGaida
esphome/components/lps22/* @nagisa
esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
@@ -326,7 +347,9 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
esphome/components/max6956/* @looping40
esphome/components/max7219digit/* @rspaargaren
@@ -345,7 +368,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
@@ -359,6 +381,7 @@ esphome/components/midea_ir/* @dudanov
esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mistral_ir/* @lerhum
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
@@ -397,6 +420,8 @@ esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/ns2009/* @tchilov
esphome/components/ns2009/touchscreen/* @tchilov
esphome/components/number/* @esphome/core
esphome/components/one_wire/* @ssieb
esphome/components/online_image/* @clydebarrow @guillempages
@@ -411,6 +436,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
esphome/components/pcm5122/* @remcom
esphome/components/pd_pioneer_ir/* @martinsah
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
@@ -429,6 +455,7 @@ esphome/components/pn7160/* @jesserockz @kbx81
esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/pn71xx/* @jesserockz @kbx81
esphome/components/power_management/* @rwrozelle
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
@@ -449,6 +476,8 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/remko_ar715/* @naKruul
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
@@ -468,6 +497,7 @@ esphome/components/runtime_stats/* @bdraco
esphome/components/rx8025t/* @remcom
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
esphome/components/samsung/* @jorofi
esphome/components/scd4x/* @martgras @sjtrny
esphome/components/script/* @esphome/core
esphome/components/sdl/* @bdm310 @clydebarrow
@@ -496,11 +526,14 @@ esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sh1122_base/* @pl4nkton
esphome/components/sh1122_spi/* @pl4nkton
esphome/components/sha256/* @esphome/core
esphome/components/shelly_dimmer/* @edge90 @rnauber
esphome/components/sht3xd/* @mrtoy-me
esphome/components/sht4x/* @sjtrny
esphome/components/shutdown/* @esphome/core @jsuanet
esphome/components/siemens_ira211/* @fonix232
esphome/components/sigma_delta_output/* @Cat-Ion
esphome/components/sim800l/* @glmnet
esphome/components/sm10bit_base/* @Cossid
@@ -517,6 +550,7 @@ esphome/components/sound_level/* @kahrendt
esphome/components/spa06_base/* @danielkent-net
esphome/components/spa06_i2c/* @danielkent-net
esphome/components/spa06_spi/* @danielkent-net
esphome/components/spd2010/* @grischard
esphome/components/speaker/* @jesserockz @kahrendt
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
esphome/components/speaker_source/* @kahrendt
@@ -562,12 +596,13 @@ esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
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
@@ -579,12 +614,15 @@ esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
esphome/components/tinyusb/* @kbx81
esphome/components/tinyusb_keyboard/* @frederikbolding
esphome/components/tlc5947/* @rnauber
esphome/components/tlc5971/* @IJIJI
esphome/components/tm1621/* @Philippe12
esphome/components/tm1637/* @glmnet
esphome/components/tm1638/* @skykingjwc
esphome/components/tm1651/* @mrtoy-me
esphome/components/tmc2209/* @remcom
esphome/components/tmc22xx/* @remcom
esphome/components/tmp102/* @timsavage
esphome/components/tmp1075/* @sybrenstuvel
esphome/components/tmp117/* @Azimath
@@ -636,7 +674,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @glmnet
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
@@ -657,6 +695,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51
esphome/components/xiaomi_hhccjcy10/* @fariouche
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
esphome/components/xiaomi_mhoc303/* @drug123
esphome/components/xiaomi_mhoc401/* @vevsvevs
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0-dev
PROJECT_NUMBER = 2026.11.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+209 -17
View File
@@ -7,6 +7,7 @@ import importlib
import logging
import os
from pathlib import Path
import platform
import re
import sys
import time
@@ -47,14 +48,18 @@ 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
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
@@ -68,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,
@@ -1230,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)
@@ -1280,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; "
@@ -1293,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``.
@@ -1305,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, []):
platform = ota_item.get(CONF_PLATFORM)
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[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 "
@@ -1352,9 +1376,15 @@ def _upload_via_native_api(
# Fail closed: an encryption block whose key did not resolve must never
# fall back to a plaintext upload
noise_psk = None
old_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)
@@ -1367,6 +1397,8 @@ def _upload_via_native_api(
# Ensure the key is a string, as required by the underlying OTA implementation.
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
noise_psk = str(noise_psk)
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
old_noise_psk = str(old_key)
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
noise_psk = str(api_key)
@@ -1416,9 +1448,22 @@ def _upload_via_native_api(
noise_psk,
plaintext_fallback=plaintext_fallback,
allow_plaintext_upload=allow_plaintext_upload,
old_noise_psk=old_noise_psk,
)
def _old_key_notice(config: ConfigType) -> None:
"""After `run`, whose image was just built from this config; a plain
`upload` may send a stale image that still carries the previous key."""
from esphome import espota2
for item in config.get(CONF_OTA, []):
if item.get(CONF_PLATFORM) == CONF_ESPHOME and (
item.get(CONF_ENCRYPTION) or {}
).get(espota2.CONF_OLD_KEY):
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
@@ -1736,7 +1781,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,
@@ -1784,6 +1882,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
@@ -1809,6 +1908,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
exit_code, successful_device = upload_program(config, args, devices)
if exit_code == 0:
_LOGGER.info("Successfully uploaded program.")
_old_key_notice(config)
else:
_LOGGER.warning("Failed to upload to %s", devices)
return exit_code
@@ -1945,7 +2045,10 @@ def run_multiple_configs(
safe_print()
cmd = command_builder(f)
rc = run_external_process(*cmd)
# The parent already logged the Intel macOS warning; children skip it.
rc = run_external_process(
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
)
if rc == 0:
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
@@ -2201,6 +2304,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(
@@ -2263,6 +2385,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]
)
@@ -2358,6 +2491,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).",
@@ -2438,6 +2576,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",
@@ -2566,9 +2709,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:
@@ -2614,12 +2766,34 @@ def _warn_if_source_tree_mismatch() -> None:
)
_INTEL_MACOS_REMOVAL = "2027.6.0"
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
def _warn_if_intel_macos() -> None:
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
if (
not IS_MACOS
or platform.machine() != "x86_64"
or _INTEL_MACOS_WARNED_ENV in os.environ
):
return
_LOGGER.warning(
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
"so future releases will not install on this machine. On an Apple "
"Silicon Mac, switch to a native arm64 Python.",
_INTEL_MACOS_REMOVAL,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
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(
@@ -2632,7 +2806,25 @@ 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()
if args.command in PRE_CONFIG_ACTIONS:
try:
+14 -14
View File
@@ -49,40 +49,40 @@ class FrameworkRelease(NamedTuple):
# Arduino core version -> its build in esphome-libs/arduino-esp8266
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
Version(3, 1, 2): FrameworkRelease(
"3.1.2-esphome.1",
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
37189311,
"3.1.2-esphome.3",
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
37185650,
),
}
TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf"
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
# generator's compile flags are tuned to it.
TOOLCHAIN_VERSION = "10.3.0-esphome.2"
TOOLCHAIN_VERSION = "10.3.0-esphome.3"
_TOOLCHAIN_RELEASES = (
"https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/"
)
# Registry system tag -> (sha256, size) of that host's archive
TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = {
"darwin_arm64": (
"849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201",
60830321,
"ad0ea929238d9b527c43e0d20257acc2a5680bdb0d2b1cf8f2c99169786217e9",
60747260,
),
"darwin_x86_64": (
"ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897",
64149487,
"997090cac80c8eb59604ec52b73fb3568e5f2d9b2971f83c3f2a5c545c9c949c",
64065752,
),
"linux_aarch64": (
"60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6",
67573397,
"403f14d0d5755682fcd6eaea1e4c457a1d8cad30690e52fb0c804d8db266b908",
67487067,
),
"linux_x86_64": (
"1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4",
68431336,
"64f21fa3ba736b363dc8c234dbbb39e005e574e33de08a78e9a00c55a64ae706",
68343668,
),
"windows_amd64": (
"af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b",
67664137,
"1e62abf8df041ce5158f5770dffef955f2f981ce7f7adee88fcd5a8197705aa6",
67536405,
),
}
+301 -105
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import string
@@ -21,9 +21,9 @@ from esphome.const import (
CONF_TYPE_ID,
CONF_UPDATE_INTERVAL,
)
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
from esphome.cpp_generator import (
FlashStringLiteral,
Expression,
LambdaExpression,
MockObj,
MockObjClass,
@@ -35,6 +35,32 @@ from esphome.types import ConfigType, SafeExpType
from esphome.util import Registry
def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj:
"""Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr."""
if not data:
return cg.nullptr
return cg.shared_progmem_array(
name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data))
)
async def templatable_bytes(
value: Any,
args: TemplateArgsType,
set_template: MockObj,
set_static: MockObj,
table_name: str,
) -> None:
"""Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static."""
if cg.is_template(value):
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
cg.add(set_template(fn))
elif len(value) > 0xFFFF:
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
else:
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
def maybe_simple_id(*validators):
"""Allow a raw ID to be specified in place of a config block.
If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's
@@ -219,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:
@@ -233,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.
"""
@@ -251,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):
@@ -265,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)``."""
@@ -339,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:
@@ -373,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]:
@@ -417,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.
@@ -451,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}();",
@@ -467,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(
@@ -495,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)
@@ -646,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
)
@@ -708,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,
@@ -822,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
@@ -849,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
@@ -879,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
@@ -1015,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(
@@ -1047,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.
@@ -1058,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.
@@ -1066,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."""
@@ -1090,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(
@@ -1111,4 +1304,7 @@ async def build_callback_automations(
entry.args,
conf,
forwarder=entry.forwarder,
params=entry.params,
forward=entry.forward,
when=entry.when,
)
+42 -27
View File
@@ -54,6 +54,7 @@ from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_FLASH_SIZE,
KEY_SCANF_FLOAT,
THROW_STUBS_HEADER,
)
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_project_cxx_compile_flags
@@ -1056,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",
@@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
core_exclude |= _CORE_EXCLUDE_WAVEFORM
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries)
archives += lib_archives
# One source of truth with the PlatformIO path: esp8266/__init__ pins
# build_src_flags (the throw_stubs force-include); -include paths
# resolve against the source root
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
# the PlatformIO path; -include paths resolve against the source root
src_other: list[str] = []
src_includes: list[str] = []
src_it = iter(
@@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
src_includes.append(tok[len("-include") :])
else:
src_other.append(_shell_token(tok))
# The throw stubs abort: none anywhere with exceptions on, else the core and
# libraries get them too (src keeps them after its pch include)
if config.exceptions:
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
framework_flags = ""
if THROW_STUBS_HEADER in src_includes:
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
lines.append(f"frameworkflags = {stubs_flag}")
framework_flags = "$frameworkflags"
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(
lines, variant_sources, variant_dir, "variant", flags=framework_flags
)
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines,
collect_sources(core_dir, core_exclude),
core_dir,
"core",
flags=framework_flags,
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
archives += lib_archives
# One shared variable instead of repeating the flags line on every src
# edge (hundreds of edges in a real project)
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
+51 -15
View File
@@ -237,6 +237,35 @@ def idf_macro_matches(idf_path: Path) -> bool:
return live == _EXPECTED_MACRO
def _cmake_args_for_write(minimal: bool) -> dict[str, str]:
"""The cg.add_cmake_arg() values for one project write.
The discovery (minimal) write lets every built-in component register so
the list it finds, cached per target, serves any exclusion set; the full
write drops the excluded names from that list and passes all of them to
IDF. Names outside the framework's components directory (managed
components) stay excluded on the discovery write too, since the list
only holds built-in ones. A checkout supplied through IDF_PATH caches
nothing, so its discovery keeps every exclusion.
"""
from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path
args = dict(CORE.cmake_args)
if (
not minimal
or not _esphome_manages_idf()
or not (excluded := args.get("EXCLUDE_COMPONENTS"))
):
return args
root = _get_idf_path() / "components"
outside = [name for name in excluded.split(";") if not (root / name).is_dir()]
if outside:
args["EXCLUDE_COMPONENTS"] = ";".join(outside)
else:
del args["EXCLUDE_COMPONENTS"]
return args
def get_project_cmakelists(
minimal: bool = False, builtin_components: list[str] | None = None
) -> str:
@@ -300,11 +329,10 @@ def get_project_cmakelists(
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
# (discovery) writes too so excluded components never register.
# set when project.cmake seeds the component list.
cmake_args = "\n".join(
f"set({name} {_cmake_quote(value)})"
for name, value in sorted(CORE.cmake_args.items())
for name, value in sorted(_cmake_args_for_write(minimal).items())
)
# Per-project list exposed as a CMake variable so converted PIO libs
@@ -327,10 +355,10 @@ def get_project_cmakelists(
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
# Excluded components are dropped here as well: a stale
# project_description.json from a build without exclusions may still
# list them, and requiring an excluded component pulls it back into
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
# Excluded components are dropped here as well: the discovery list
# (cached per target) still holds them, and requiring an excluded
# component pulls it back into the build (IDF requirement expansion
# overrides EXCLUDE_COMPONENTS).
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
# two can never disagree within one generated file.
builtin_components_property = (
@@ -397,15 +425,24 @@ project({CORE.name})
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
# target depends on the map and can then be built in the same run as all.
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
# IDF's size target depends on the map, so ninja has to know the link
# writes it for size to build in the same run as all. IDF declares the
# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja
# rejects two rules for one output, so declare it here only when IDF has
# not. add_custom_command(OUTPUT) marks its outputs GENERATED.
get_source_file_property(esphome_map_declared
${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED)
if(esphome_map_declared)
set(esphome_map_byproducts)
else()
set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map)
endif()
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
${{esphome_map_byproducts}}
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
@@ -554,10 +591,9 @@ def write_project(
)
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
# config gains mqtt) requires a fresh discovery pass before the
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
# reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS
# property drops excluded names, so re-including one (e.g. a config
# gains mqtt) needs a reconfigure to rebuild it from the discovery list.
write_file_if_changed(
CORE.relative_build_path("exclude_components.esphomeinternal"),
";".join(get_excluded_builtin_components()),
+1 -1
View File
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
+18 -9
View File
@@ -74,33 +74,40 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
@@ -207,10 +214,12 @@ def compile_edges(
def library_edges(
lines: list[str], libraries: list[ArduinoLibrary]
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
) -> tuple[list[str], list[str]]:
"""Emit every library's compile and archive edges.
``extra_flags`` (raw ninja text) precede each library's own flags.
Returns the archive names and the objects that link directly. A
library's own include dirs lead its compile lines, as PlatformIO searched
them first: the include path is one global list, so another library's
@@ -232,7 +241,7 @@ def library_edges(
lib.sources,
common_parent(lib.sources),
f"lib/{lib.name}",
flags=" ".join(_shell_token(f) for f in lib.flags),
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
)
if not lib.lib_archive:
+20 -12
View File
@@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
)
# Matches !secret references in YAML text. An optional surrounding
# quote pair around the key is allowed and ignored: YAML treats
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
# intentionally a simple regex scan rather than a YAML parse — it may
# match inside comments or multi-line strings, which is the conservative
# direction (include more secrets rather than fewer).
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <key>`` references."""
keys: set[str] = set()
for fpath in yaml_files:
try:
text = fpath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError) as err:
_LOGGER.warning(
"Could not scan %s for !secret references (%s); the bundled "
"secret set may be incomplete",
fpath,
err,
)
continue
for match in _SECRET_RE.finditer(text):
keys.add(match.group(1))
keys |= yaml_util.find_secret_references(text)
return keys
@@ -422,6 +418,18 @@ class ConfigBundleCreator:
must ship every candidate so the remote build can pick any one.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
if discovered.load_errors:
_LOGGER.warning(
"Bundle may be incomplete; could not load all configuration files: %s",
"; ".join(discovered.load_errors),
)
if discovered.unresolved:
_LOGGER.warning(
"Bundle may be incomplete; %d !include path(s) use "
"substitutions and cannot be captured: %s",
len(discovered.unresolved),
", ".join(discovered.unresolved),
)
self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
+4
View File
@@ -31,16 +31,20 @@ from esphome.cpp_generator import ( # noqa: F401
add_global,
add_library,
add_platformio_option,
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,
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@MrSuicideParrot"]
DOMAIN = "a01nyub"
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::a01nyub {
static const char *const TAG = "a01nyub.sensor";
ESPHOME_LOG_TAG(TAG, "a01nyub.sensor");
void A01nyubComponent::loop() {
uint8_t data;
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@TH-Braemer"]
DOMAIN = "a02yyuw"
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::a02yyuw {
static const char *const TAG = "a02yyuw.sensor";
ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor");
void A02yyuwComponent::loop() {
uint8_t data;
+1
View File
@@ -0,0 +1 @@
DOMAIN = "a4988"
+1 -1
View File
@@ -3,7 +3,7 @@
namespace esphome::a4988 {
static const char *const TAG = "a4988.stepper";
ESPHOME_LOG_TAG(TAG, "a4988.stepper");
void A4988::setup() {
if (this->sleep_pin_ != nullptr) {
@@ -1 +1,2 @@
CODEOWNERS = ["@DAVe3283"]
DOMAIN = "absolute_humidity"
@@ -3,7 +3,7 @@
namespace esphome::absolute_humidity {
static const char *const TAG{"absolute_humidity.sensor"};
ESPHOME_LOG_TAG(TAG, "absolute_humidity.sensor");
void AbsoluteHumidityComponent::setup() {
this->temperature_sensor_->add_on_state_callback([this](float state) {
+1
View File
@@ -0,0 +1 @@
DOMAIN = "ac_dimmer"
+1 -1
View File
@@ -14,7 +14,7 @@
namespace esphome::ac_dimmer {
static const char *const TAG = "ac_dimmer";
ESPHOME_LOG_TAG(TAG, "ac_dimmer");
// Global array to store dimmer objects
static AcDimmerDataStore *all_dimmers[32]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -9,7 +9,7 @@
#include "esp_clk_tree.h"
#include "soc/clk_tree_defs.h"
static const char *const TAG = "hw_timer_esp_idf";
ESPHOME_LOG_TAG(TAG, "hw_timer_esp_idf");
namespace esphome::ac_dimmer {
+1
View File
@@ -9,6 +9,7 @@ from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
DOMAIN = "adalight"
adalight_ns = cg.esphome_ns.namespace("adalight")
AdalightLightEffect = adalight_ns.class_(
@@ -3,7 +3,7 @@
namespace esphome::adalight {
static const char *const TAG = "adalight_light_effect";
ESPHOME_LOG_TAG(TAG, "adalight_light_effect");
static const uint32_t ADALIGHT_ACK_INTERVAL = 1000;
static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000;
+1
View File
@@ -22,6 +22,7 @@ from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "adc"
adc_ns = cg.esphome_ns.namespace("adc")
+4 -4
View File
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
bool output_raw_{false};
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
InternalGPIOPin *pin_;
SamplingMode sampling_mode_{SamplingMode::AVG};
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
#ifdef USE_ESP32
float sample_autorange_();
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
bool autorange_{false};
adc_oneshot_unit_handle_t adc_handle_{nullptr};
adc_cali_handle_t calibration_handle_{nullptr};
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
adc_channel_t channel_{};
adc_unit_t adc_unit_{};
struct SetupFlags {
+1 -1
View File
@@ -3,7 +3,7 @@
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
const LogString *sampling_mode_to_str(SamplingMode mode) {
switch (mode) {
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
@@ -5,7 +5,7 @@
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
+1 -1
View File
@@ -17,7 +17,7 @@
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
+1 -1
View File
@@ -7,7 +7,7 @@
namespace esphome::adc {
static const char *const TAG = "adc";
ESPHOME_LOG_TAG(TAG, "adc");
void ADCSensor::setup() {
if (!adc_is_ready_dt(this->channel_)) {
+24 -8
View File
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
CONF_SAMPLES = "samples"
CONF_SAMPLING_MODE = "sampling_mode"
# Keep in sync with the initializers in adc_sensor.h
DEFAULT_ATTENUATION = "0db"
DEFAULT_SAMPLING_MODE = "avg"
DEFAULT_SAMPLES = 1
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
):
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
if (
config.get(CONF_ATTENUATION, None) == "auto"
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
):
raise cv.Invalid(
"Automatic attenuation cannot be used when multisampling is set"
)
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): validate_adc_pin,
cv.Optional(CONF_RAW, default=False): cv.boolean,
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
cv.only_on_esp32, _attenuation
),
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
min=1, max=255
),
cv.Optional(
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
): _sampling_mode,
}
)
.extend(cv.polling_component_schema("60s")),
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_output_raw(config[CONF_RAW]))
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
# Skip the setters when the config matches the C++ initializers.
if config[CONF_RAW]:
cg.add(var.set_output_raw(True))
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
cg.add(var.set_sample_count(samples))
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
cg.add(var.set_sampling_mode(sampling_mode))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))
else:
elif attenuation != DEFAULT_ATTENUATION:
cg.add(var.set_attenuation(attenuation))
variant = get_esp32_variant()
@@ -5,6 +5,7 @@ from esphome.const import CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
DOMAIN = "adc128s102"
MULTI_CONF = True
CODEOWNERS = ["@DeerMaximum"]
+1 -1
View File
@@ -3,7 +3,7 @@
namespace esphome::adc128s102 {
static const char *const TAG = "adc128s102";
ESPHOME_LOG_TAG(TAG, "adc128s102");
float ADC128S102::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -4,7 +4,7 @@
namespace esphome::adc128s102 {
static const char *const TAG = "adc128s102.sensor";
ESPHOME_LOG_TAG(TAG, "adc128s102.sensor");
ADC128S102Sensor::ADC128S102Sensor(uint8_t channel) : channel_(channel) {}
@@ -0,0 +1 @@
DOMAIN = "addressable_light"
@@ -3,7 +3,7 @@
namespace esphome::addressable_light {
static const char *const TAG = "addressable_light.display";
ESPHOME_LOG_TAG(TAG, "addressable_light.display");
int AddressableLightDisplay::get_width_internal() { return this->width_; }
int AddressableLightDisplay::get_height_internal() { return this->height_; }
+1
View File
@@ -0,0 +1 @@
DOMAIN = "ade7880"
+1 -1
View File
@@ -15,7 +15,7 @@
namespace esphome::ade7880 {
static const char *const TAG = "ade7880";
ESPHOME_LOG_TAG(TAG, "ade7880");
void IRAM_ATTR ADE7880Store::gpio_intr(ADE7880Store *arg) { arg->reset_done = true; }
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@angelnu"]
DOMAIN = "ade7953"
@@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@angelnu"]
DOMAIN = "ade7953_base"
CONF_CURRENT_A = "current_a"
CONF_CURRENT_B = "current_b"
@@ -5,7 +5,7 @@
namespace esphome::ade7953_base {
static const char *const TAG = "ade7953";
ESPHOME_LOG_TAG(TAG, "ade7953");
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
@@ -1 +1,2 @@
CODEOWNERS = ["@angelnu"]
DOMAIN = "ade7953_i2c"
@@ -4,7 +4,7 @@
namespace esphome::ade7953_i2c {
static const char *const TAG = "ade7953";
ESPHOME_LOG_TAG(TAG, "ade7953");
void AdE7953I2c::dump_config() {
ESP_LOGCONFIG(TAG, "ADE7953_i2c:");
@@ -1 +1,2 @@
CODEOWNERS = ["@angelnu"]
DOMAIN = "ade7953_spi"
@@ -4,7 +4,7 @@
namespace esphome::ade7953_spi {
static const char *const TAG = "ade7953";
ESPHOME_LOG_TAG(TAG, "ade7953");
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
+1
View File
@@ -5,6 +5,7 @@ from esphome.const import CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
DOMAIN = "ads1115"
MULTI_CONF = True
ads1115_ns = cg.esphome_ns.namespace("ads1115")
+1 -1
View File
@@ -4,7 +4,7 @@
namespace esphome::ads1115 {
static const char *const TAG = "ads1115";
ESPHOME_LOG_TAG(TAG, "ads1115");
static const uint8_t ADS1115_REGISTER_CONVERSION = 0x00;
static const uint8_t ADS1115_REGISTER_CONFIG = 0x01;
@@ -4,7 +4,7 @@
namespace esphome::ads1115 {
static const char *const TAG = "ads1115.sensor";
ESPHOME_LOG_TAG(TAG, "ads1115.sensor");
float ADS1115Sensor::sample() {
return this->parent_->request_measurement(this->multiplexer_, this->gain_, this->resolution_, this->samplerate_);
+1
View File
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
CODEOWNERS = ["@solomondg1"]
DEPENDENCIES = ["spi"]
DOMAIN = "ads1118"
MULTI_CONF = True
CONF_ADS1118_ID = "ads1118_id"
+1 -1
View File
@@ -4,7 +4,7 @@
namespace esphome::ads1118 {
static const char *const TAG = "ads1118";
ESPHOME_LOG_TAG(TAG, "ads1118");
static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111;
void ADS1118::setup() {
@@ -4,7 +4,7 @@
namespace esphome::ads1118 {
static const char *const TAG = "ads1118.sensor";
ESPHOME_LOG_TAG(TAG, "ads1118.sensor");
void ADS1118Sensor::dump_config() {
LOG_SENSOR(" ", "ADS1118 Sensor", this);
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@mak-42"]
DOMAIN = "ags10"
+1 -1
View File
@@ -4,7 +4,7 @@
#include <cinttypes>
namespace esphome::ags10 {
static const char *const TAG = "ags10";
ESPHOME_LOG_TAG(TAG, "ags10");
// Data acquisition.
static const uint8_t REG_TVOC = 0x00;
+1
View File
@@ -0,0 +1 @@
DOMAIN = "aht10"
+1 -1
View File
@@ -19,7 +19,7 @@
namespace esphome::aht10 {
static const char *const TAG = "aht10";
ESPHOME_LOG_TAG(TAG, "aht10");
static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00};
static const uint8_t AHT20_INITIALIZE_CMD[] = {0xBE, 0x08, 0x00};
static const uint8_t AHT10_MEASURE_CMD[] = {0xAC, 0x33, 0x00};
+1
View File
@@ -0,0 +1 @@
DOMAIN = "aic3204"
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::aic3204 {
static const char *const TAG = "aic3204";
ESPHOME_LOG_TAG(TAG, "aic3204");
#define ERROR_CHECK(err, msg) \
if (!(err)) { \
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
AUTO_LOAD = ["ble_device_base"]
CODEOWNERS = ["@jeromelaban"]
DOMAIN = "airthings_ble"
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
AirthingsListener = airthings_ble_ns.class_(
@@ -4,7 +4,7 @@
namespace esphome::airthings_ble {
static const char *const TAG = "airthings_ble";
ESPHOME_LOG_TAG(TAG, "airthings_ble");
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
for (auto &it : device.get_manufacturer_datas()) {
@@ -24,6 +24,7 @@ from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@ncareau", "@jeromelaban"]
DOMAIN = "airthings_wave_base"
DEPENDENCIES = ["ble_client"]
@@ -8,7 +8,10 @@
namespace esphome::airthings_wave_base {
static const char *const TAG = "airthings_wave_base";
ESPHOME_LOG_TAG(TAG, "airthings_wave_base");
static constexpr uint32_t BATTERY_READ_TIMEOUT_ID = 0;
static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1;
void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
@@ -181,7 +184,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) {
// read the battery again at the configured update interval
if (this->battery_update_interval_ != this->update_interval_) {
this->read_battery_next_update_ = false;
this->set_timeout("battery", this->battery_update_interval_,
this->set_timeout(BATTERY_READ_TIMEOUT_ID, this->battery_update_interval_,
[this]() { this->read_battery_next_update_ = true; });
}
}
@@ -204,7 +207,7 @@ void AirthingsWaveBase::response_received_() {
}
void AirthingsWaveBase::set_response_timeout_() {
this->set_timeout("response_timeout", 30 * 1000, [this]() {
this->set_timeout(RESPONSE_TIMEOUT_ID, 30 * 1000, [this]() {
this->responses_pending_ = 1;
this->response_received_();
});
@@ -1 +1,2 @@
CODEOWNERS = ["@ncareau"]
DOMAIN = "airthings_wave_mini"
@@ -4,7 +4,7 @@
namespace esphome::airthings_wave_mini {
static const char *const TAG = "airthings_wave_mini";
ESPHOME_LOG_TAG(TAG, "airthings_wave_mini");
void AirthingsWaveMini::read_sensors(uint8_t *raw_value, uint16_t value_len) {
auto *value = (WaveMiniReadings *) raw_value;
@@ -1 +1,2 @@
CODEOWNERS = ["@jeromelaban", "@precurse"]
DOMAIN = "airthings_wave_plus"
@@ -4,7 +4,7 @@
namespace esphome::airthings_wave_plus {
static const char *const TAG = "airthings_wave_plus";
ESPHOME_LOG_TAG(TAG, "airthings_wave_plus");
void AirthingsWavePlus::read_sensors(uint8_t *raw_value, uint16_t value_len) {
auto *value = (WavePlusReadings *) raw_value;
@@ -21,6 +21,7 @@ from esphome.core.entity_helpers import (
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@grahambrown11", "@hwstar"]
DOMAIN = "alarm_control_panel"
IS_PLATFORM_COMPONENT = True
CONF_ON_TRIGGERED = "on_triggered"
@@ -10,7 +10,7 @@
namespace esphome::alarm_control_panel {
static const char *const TAG = "alarm_control_panel";
ESPHOME_LOG_TAG(TAG, "alarm_control_panel");
AlarmControlPanelCall AlarmControlPanel::make_call() { return AlarmControlPanelCall(this); }
@@ -6,7 +6,7 @@
namespace esphome::alarm_control_panel {
static const char *const TAG = "alarm_control_panel";
ESPHOME_LOG_TAG(TAG, "alarm_control_panel");
AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent_(parent) {}
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@jan-hofmeier"]
DOMAIN = "alpha3"
+1 -1
View File
@@ -7,7 +7,7 @@
namespace esphome::alpha3 {
static const char *const TAG = "alpha3";
ESPHOME_LOG_TAG(TAG, "alpha3");
void Alpha3::dump_config() {
ESP_LOGCONFIG(TAG, "ALPHA3");
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@swoboda1337"]
DOMAIN = "am2315c"
+1 -1
View File
@@ -26,7 +26,7 @@
namespace esphome::am2315c {
static const char *const TAG = "am2315c";
ESPHOME_LOG_TAG(TAG, "am2315c");
bool AM2315C::reset_register_(uint8_t reg) {
// code based on demo code sent by www.aosong.com
+1
View File
@@ -0,0 +1 @@
DOMAIN = "am2320"
+1 -1
View File
@@ -10,7 +10,7 @@
namespace esphome::am2320 {
static const char *const TAG = "am2320";
ESPHOME_LOG_TAG(TAG, "am2320");
void AM2320Component::update() {
uint8_t data[8];
+1
View File
@@ -1 +1,2 @@
CODEOWNERS = ["@buxtronix"]
DOMAIN = "am43"
+1 -1
View File
@@ -5,7 +5,7 @@
namespace esphome::am43 {
static const char *const TAG = "am43_cover";
ESPHOME_LOG_TAG(TAG, "am43_cover");
using namespace esphome::cover;
@@ -6,7 +6,7 @@
namespace esphome::am43 {
static const char *const TAG = "am43";
ESPHOME_LOG_TAG(TAG, "am43");
void Am43::dump_config() {
ESP_LOGCONFIG(TAG, "AM43");
@@ -1 +1,2 @@
CODEOWNERS = ["@ianchi"]
DOMAIN = "analog_threshold"
@@ -3,7 +3,7 @@
namespace esphome::analog_threshold {
static const char *const TAG = "analog_threshold.binary_sensor";
ESPHOME_LOG_TAG(TAG, "analog_threshold.binary_sensor");
void AnalogThresholdBinarySensor::setup() {
float sensor_value = this->sensor_->get_state();
+1
View File
@@ -0,0 +1 @@
DOMAIN = "anova"
+1 -1
View File
@@ -5,7 +5,7 @@
namespace esphome::anova {
static const char *const TAG = "anova";
ESPHOME_LOG_TAG(TAG, "anova");
using namespace esphome::climate;
+3
View File
@@ -0,0 +1,3 @@
"""Support for ScioSense APC1 air quality sensor."""
DOMAIN = "apc1"
+352
View File
@@ -0,0 +1,352 @@
#include "apc1.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <cinttypes>
#include <cmath>
namespace esphome::apc1 {
ESPHOME_LOG_TAG(TAG, "apc1");
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58;
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19;
// Give a freshly powered sensor time to boot before the first retry
static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000;
// Re-send the init commands at most this often while no frames arrive
static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000;
// Active mode streams a frame every second; this long without one means the sensor stopped
static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000;
// Drop a partial frame when the bytes stop mid-way
static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200;
// Frames and commands end with the 16-bit sum of every byte before it
static uint16_t apc1_checksum(const uint8_t *data, size_t len) {
uint16_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
void APC1Component::setup() {
if (this->set_pin_ != nullptr) {
this->set_pin_->setup();
this->set_pin_->digital_write(true);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
}
// Attempt initial communication immediately for warm restarts
this->initialize_device_();
// Schedule delayed initialization to catch sensors during cold power-on bootup
this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); });
}
void APC1Component::initialize_device_() {
this->last_init_attempt_ = App.get_loop_component_start_time();
if (this->active_mode_ && !this->idle_) {
this->set_active_mode();
}
this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000);
}
void APC1Component::dump_config() {
ESP_LOGCONFIG(TAG, "APC1:");
LOG_PIN(" Set Pin: ", this->set_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_);
LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_);
LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_);
LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_);
LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_);
LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_);
LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_);
LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_);
LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_);
LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_);
LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_);
LOG_SENSOR(" ", "eCO2", this->eco2_sensor_);
LOG_SENSOR(" ", "AQI", this->aqi_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_);
LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_);
LOG_SENSOR(" ", "RS0", this->rs0_sensor_);
LOG_SENSOR(" ", "RS2", this->rs2_sensor_);
LOG_SENSOR(" ", "RS3", this->rs3_sensor_);
LOG_SENSOR(" ", "Error Code", this->error_code_sensor_);
}
void APC1Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
// If in active mode and not idle, retry periodically if no valid data received
if (this->active_mode_ && !this->idle_) {
if (this->last_valid_frame_ == 0) {
if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands...");
this->initialize_device_();
}
} else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS &&
now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_);
this->initialize_device_();
}
}
if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) {
ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer");
this->rx_index_ = 0;
}
while (this->available()) {
this->last_transmission_ = now;
uint8_t byte;
this->read_byte(&byte);
if (this->rx_index_ == 0) {
if (byte == APC1_HEADER_1) {
this->rx_buffer_[this->rx_index_++] = byte;
}
continue;
}
if (this->rx_index_ == 1) {
if (byte == APC1_HEADER_2) {
this->rx_buffer_[this->rx_index_++] = byte;
} else if (byte != APC1_HEADER_1) {
this->rx_index_ = 0;
}
continue;
}
this->rx_buffer_[this->rx_index_++] = byte;
if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) {
continue;
}
// The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a
// corrupt length from wrapping the total and walking past the buffer
const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]);
if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) {
ESP_LOGW(TAG, "Invalid frame length: %u", payload_len);
this->rx_index_ = 0;
continue;
}
const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE;
if (this->rx_index_ == total_len) {
this->parse_frame_(total_len);
this->rx_index_ = 0;
}
}
}
void APC1Component::parse_frame_(uint16_t total_len) {
uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]);
uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2);
if (expected_checksum != calculated_checksum) {
ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum,
expected_checksum);
return;
}
if (total_len == APC1_FRAME_SIZE_MEASUREMENT) {
this->parse_measurement_frame_();
} else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) {
this->parse_device_info_frame_();
} else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) {
ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]);
} else {
ESP_LOGD(TAG, "Received frame of unknown length %u", total_len);
}
}
// One message per error bit, bit 0 first
PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault",
"Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault");
void APC1Component::parse_measurement_frame_() {
const uint32_t now = App.get_loop_component_start_time();
this->last_valid_frame_ = now;
uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE];
if (error_code != this->last_error_code_) {
if (error_code != 0) {
for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) {
if (error_code & (1 << bit)) {
ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX)));
}
}
this->status_set_warning(LOG_STR("Sensor reported hardware error"));
} else {
ESP_LOGI(TAG, "APC1: Hardware error cleared");
this->status_clear_warning();
}
this->last_error_code_ = error_code;
if (this->error_code_sensor_ != nullptr) {
this->error_code_sensor_->publish_state(error_code);
}
}
uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI];
if (aqi == 0) {
if (!this->gas_warming_up_) {
this->gas_warming_up_ = true;
ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)");
}
} else if (this->gas_warming_up_) {
this->gas_warming_up_ = false;
ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi);
}
// Measurement payload data (bytes 4-57): big-endian sensor readings.
// The values below feed the debug summary, so they are always decoded; every
// other reading is only decoded when its sensor is configured.
const auto u16 = [this](uint8_t offset) {
return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]);
};
const auto u32 = [this](uint8_t offset) {
return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2],
this->rx_buffer_[offset + 3]);
};
uint16_t pm_1_0 = u16(10);
uint16_t pm_2_5 = u16(12);
uint16_t pm_10_0 = u16(14);
uint16_t tvoc = u16(28);
uint16_t eco2 = u16(30);
float temp_comp = static_cast<int16_t>(u16(34)) / 10.0f;
float hum_comp = u16(36) / 10.0f;
ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0,
pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp);
if (this->pm_1_0_std_sensor_ != nullptr)
this->pm_1_0_std_sensor_->publish_state(u16(4));
if (this->pm_2_5_std_sensor_ != nullptr)
this->pm_2_5_std_sensor_->publish_state(u16(6));
if (this->pm_10_0_std_sensor_ != nullptr)
this->pm_10_0_std_sensor_->publish_state(u16(8));
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0);
if (this->pm_0_3um_sensor_ != nullptr)
this->pm_0_3um_sensor_->publish_state(u16(16));
if (this->pm_0_5um_sensor_ != nullptr)
this->pm_0_5um_sensor_->publish_state(u16(18));
if (this->pm_1_0um_sensor_ != nullptr)
this->pm_1_0um_sensor_->publish_state(u16(20));
if (this->pm_2_5um_sensor_ != nullptr)
this->pm_2_5um_sensor_->publish_state(u16(22));
if (this->pm_5_0um_sensor_ != nullptr)
this->pm_5_0um_sensor_->publish_state(u16(24));
if (this->pm_10_0um_sensor_ != nullptr)
this->pm_10_0um_sensor_->publish_state(u16(26));
if (this->tvoc_sensor_ != nullptr)
this->tvoc_sensor_->publish_state(tvoc);
if (this->eco2_sensor_ != nullptr)
this->eco2_sensor_->publish_state(eco2);
if (this->aqi_sensor_ != nullptr)
this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temp_comp);
if (this->humidity_sensor_ != nullptr)
this->humidity_sensor_->publish_state(hum_comp);
if (this->raw_temperature_sensor_ != nullptr) {
this->raw_temperature_sensor_->publish_state(static_cast<int16_t>(u16(38)) / 10.0f);
}
if (this->raw_humidity_sensor_ != nullptr)
this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f);
if (this->rs0_sensor_ != nullptr) {
this->rs0_sensor_->publish_state(u32(42));
}
if (this->rs2_sensor_ != nullptr) {
this->rs2_sensor_->publish_state(u32(50));
}
if (this->rs3_sensor_ != nullptr) {
this->rs3_sensor_->publish_state(u32(54));
}
}
void APC1Component::parse_device_info_frame_() {
const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL;
uint16_t fw_version =
encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]);
ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)",
reinterpret_cast<const char *>(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE),
encode_uint32(serial[0], serial[1], serial[2], serial[3]),
encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version);
}
void APC1Component::arm_watchdog_() {
this->last_valid_frame_ = App.get_loop_component_start_time();
this->last_init_attempt_ = this->last_valid_frame_;
}
void APC1Component::set_active_mode() {
if (!this->active_mode_) {
this->arm_watchdog_();
}
this->active_mode_ = true;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE);
}
void APC1Component::set_passive_mode() {
this->active_mode_ = false;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE);
}
void APC1Component::request_measurement() {
this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000);
}
void APC1Component::set_idle_mode() {
this->idle_ = true;
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE);
}
void APC1Component::set_measurement_mode() {
this->idle_ = false;
this->arm_watchdog_();
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD);
}
void APC1Component::send_command_(APC1Command cmd, uint16_t data) {
std::array<uint8_t, APC1_COMMAND_FRAME_SIZE> buf{
APC1_HEADER_1,
APC1_HEADER_2,
static_cast<uint8_t>(cmd),
static_cast<uint8_t>((data >> 8) & 0xFF),
static_cast<uint8_t>(data & 0xFF),
0,
0,
};
uint16_t checksum = apc1_checksum(buf.data(), 5);
buf[5] = static_cast<uint8_t>((checksum >> 8) & 0xFF);
buf[6] = static_cast<uint8_t>(checksum & 0xFF);
this->write_array(buf);
}
} // namespace esphome::apc1
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include <array>
namespace esphome::apc1 {
static constexpr uint8_t APC1_HEADER_1 = 0x42;
static constexpr uint8_t APC1_HEADER_2 = 0x4D;
static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes
static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64;
static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23;
static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8;
static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7;
enum class APC1Command : uint8_t {
APC1_COMMAND_MEASUREMENT_MODE = 0xE1,
APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2,
APC1_COMMAND_OPERATION_MODE = 0xE4,
APC1_COMMAND_READ_SENSOR_VERSION = 0xE9,
};
static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000;
static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001;
static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000;
static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001;
class APC1Component final : public uart::UARTDevice, public Component {
SUB_SENSOR(pm_1_0)
SUB_SENSOR(pm_2_5)
SUB_SENSOR(pm_10_0)
SUB_SENSOR(pm_1_0_std)
SUB_SENSOR(pm_2_5_std)
SUB_SENSOR(pm_10_0_std)
SUB_SENSOR(pm_0_3um)
SUB_SENSOR(pm_0_5um)
SUB_SENSOR(pm_1_0um)
SUB_SENSOR(pm_2_5um)
SUB_SENSOR(pm_5_0um)
SUB_SENSOR(pm_10_0um)
SUB_SENSOR(tvoc)
SUB_SENSOR(eco2)
SUB_SENSOR(aqi)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(raw_temperature)
SUB_SENSOR(raw_humidity)
SUB_SENSOR(rs0)
SUB_SENSOR(rs2)
SUB_SENSOR(rs3)
SUB_SENSOR(error_code)
public:
void setup() override;
void dump_config() override;
void loop() override;
void set_active_mode();
void set_passive_mode();
void request_measurement();
void set_idle_mode();
void set_measurement_mode();
void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; }
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
protected:
void send_command_(APC1Command cmd, uint16_t data);
void initialize_device_();
/// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot
void arm_watchdog_();
void parse_frame_(uint16_t total_len);
void parse_measurement_frame_();
void parse_device_info_frame_();
GPIOPin *set_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
uint32_t last_transmission_{0};
uint32_t last_valid_frame_{0};
uint32_t last_init_attempt_{0};
std::array<uint8_t, APC1_FRAME_SIZE_MEASUREMENT> rx_buffer_{};
uint8_t rx_index_{0};
uint8_t last_error_code_{0xFF};
bool active_mode_{true};
bool idle_{false};
bool gas_warming_up_{false};
};
} // namespace esphome::apc1
+222
View File
@@ -0,0 +1,222 @@
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_ECO2,
CONF_HUMIDITY,
CONF_ID,
CONF_PM_0_3UM,
CONF_PM_0_5UM,
CONF_PM_1_0,
CONF_PM_1_0_STD,
CONF_PM_1_0UM,
CONF_PM_2_5,
CONF_PM_2_5_STD,
CONF_PM_2_5UM,
CONF_PM_5_0UM,
CONF_PM_10_0,
CONF_PM_10_0_STD,
CONF_PM_10_0UM,
CONF_RESET_PIN,
CONF_TEMPERATURE,
CONF_TVOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_CHEMICAL_WEAPON,
ICON_MOLECULE_CO2,
ICON_OMEGA,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_COUNT_DECILITRE,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_OHM,
UNIT_PARTS_PER_BILLION,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ademuri"]
DEPENDENCIES = ["uart"]
CONF_SET_PIN = "set_pin"
CONF_RAW_TEMPERATURE = "raw_temperature"
CONF_RAW_HUMIDITY = "raw_humidity"
CONF_RS0 = "rs0"
CONF_RS2 = "rs2"
CONF_RS3 = "rs3"
CONF_ERROR_CODE = "error_code"
apc1_ns = cg.esphome_ns.namespace("apc1")
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
def _pm_mass_schema(device_class: str) -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=device_class,
state_class=STATE_CLASS_MEASUREMENT,
)
_PM_COUNT_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNT_DECILITRE,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
_TEMPERATURE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
_HUMIDITY_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
)
_RESISTANCE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_OHM,
icon=ICON_OMEGA,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APC1Component),
cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ECO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
icon=ICON_MOLECULE_CO2,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AQI): sensor.sensor_schema(
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA,
cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema(
icon="mdi:alert-circle-outline",
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
def final_validate(config: ConfigType) -> None:
schema = uart.final_validate_device_schema(
"apc1", baud_rate=9600, require_rx=True, require_tx=True
)
schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor)
await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor)
await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor)
await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor)
await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor)
await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor)
await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor)
await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor)
await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor)
await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor)
await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor)
await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor)
await sensors(CONF_TVOC, var.set_tvoc_sensor)
await sensors(CONF_ECO2, var.set_eco2_sensor)
await sensors(CONF_AQI, var.set_aqi_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor)
await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor)
await sensors(CONF_RS0, var.set_rs0_sensor)
await sensors(CONF_RS2, var.set_rs2_sensor)
await sensors(CONF_RS3, var.set_rs3_sensor)
await sensors(CONF_ERROR_CODE, var.set_error_code_sensor)
if (set_pin_config := config.get(CONF_SET_PIN)) is not None:
set_pin = await cg.gpio_pin_expression(set_pin_config)
cg.add(var.set_set_pin(set_pin))
if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None:
reset_pin = await cg.gpio_pin_expression(reset_pin_config)
cg.add(var.set_reset_pin(reset_pin))
APC1_ACTION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(APC1Component),
}
)
for _name, _call in (
("apc1.set_active_mode", "set_active_mode()"),
("apc1.set_passive_mode", "set_passive_mode()"),
("apc1.request_measurement", "request_measurement()"),
("apc1.set_idle_mode", "set_idle_mode()"),
("apc1.set_measurement_mode", "set_measurement_mode()"),
):
automation.register_apply_action(
_name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call)
)
+1
View File
@@ -2,3 +2,4 @@
# https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf
CODEOWNERS = ["@aodrenah"]
DOMAIN = "apds9306"
+1 -1
View File
@@ -7,7 +7,7 @@
namespace esphome::apds9306 {
static const char *const TAG = "apds9306";
ESPHOME_LOG_TAG(TAG, "apds9306");
enum { // APDS9306 registers
APDS9306_MAIN_CTRL = 0x00,
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@iyesin"]
DOMAIN = "apds9930"
+140
View File
@@ -0,0 +1,140 @@
#include "apds9930.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::apds9930 {
ESPHOME_LOG_TAG(TAG, "apds9930");
// Register addresses
static constexpr uint8_t APDS9930_ENABLE = 0x00;
static constexpr uint8_t APDS9930_ATIME = 0x01;
static constexpr uint8_t APDS9930_PTIME = 0x02;
static constexpr uint8_t APDS9930_PPULSE = 0x0E;
static constexpr uint8_t APDS9930_CONTROL = 0x0F;
static constexpr uint8_t APDS9930_ID = 0x12;
static constexpr uint8_t APDS9930_STATUS = 0x13;
// Command byte: repeated byte and auto-increment protocols
static constexpr uint8_t APDS9930_CMD = 0x80;
static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0;
static constexpr uint8_t APDS9930_CHIP_ID = 0x39;
// ENABLE register bits
static constexpr uint8_t APDS9930_PON = 0x01;
static constexpr uint8_t APDS9930_AEN = 0x02;
static constexpr uint8_t APDS9930_PEN = 0x04;
// STATUS register bits
static constexpr uint8_t APDS9930_AVALID = 0x01;
static constexpr uint8_t APDS9930_PVALID = 0x02;
// CONTROL register fields
static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6;
static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4;
static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2;
static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0;
static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode
static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms
static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms
static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses
// Lux calculation coefficients
static constexpr float APDS9930_DF = 52.0f;
static constexpr float APDS9930_GA = 0.49f;
static constexpr float APDS9930_ALS_B = 1.862f;
static constexpr float APDS9930_ALS_C = 0.746f;
static constexpr float APDS9930_ALS_D = 1.291f;
static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE);
static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120};
static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8};
static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step
// Lux per count at 1x gain and the fixed integration time
static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS;
// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels
static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE);
bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); }
void APDS9930Component::setup() {
uint8_t id;
if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) {
this->mark_failed(LOG_STR("Communication failed"));
return;
}
if (id != APDS9930_CHIP_ID) {
ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID);
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const uint8_t control =
(this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT |
(this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT;
uint8_t enable = APDS9930_PON;
if (this->illuminance_sensor_ != nullptr)
enable |= APDS9930_AEN;
if (this->proximity_sensor_ != nullptr)
enable |= APDS9930_PEN;
if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) ||
!this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) ||
!this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) ||
!this->write_reg_(APDS9930_ENABLE, enable)) {
this->mark_failed(LOG_STR("Configuration failed"));
}
}
void APDS9930Component::dump_config() {
ESP_LOGCONFIG(TAG,
"APDS9930:\n"
" LED Drive: %.1f mA\n"
" Proximity Gain: %ux\n"
" Ambient Light Gain: %ux",
LED_CURRENT_MAX_MA / (1 << this->led_drive_),
progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]),
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Proximity", this->proximity_sensor_);
}
void APDS9930Component::update() {
// STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H
uint8_t raw[7];
if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) {
this->status_set_warning(LOG_STR("Reading data failed"));
return;
}
this->status_clear_warning();
const uint8_t status = raw[0];
if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) {
const uint16_t ch0 = encode_uint16(raw[2], raw[1]);
const uint16_t ch1 = encode_uint16(raw[4], raw[3]);
if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) {
// A saturated channel would give a lux value that is too low, not too high
ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain");
this->illuminance_sensor_->publish_state(NAN);
} else {
const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f});
this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X /
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
}
}
if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) {
this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5]));
}
}
} // namespace esphome::apds9930
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::apds9930 {
class APDS9930Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(illuminance)
SUB_SENSOR(proximity)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_led_drive(uint8_t level) { this->led_drive_ = level; }
void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; }
void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; }
protected:
bool write_reg_(uint8_t reg, uint8_t value);
uint8_t led_drive_{0};
uint8_t proximity_gain_{0};
uint8_t ambient_gain_{0};
};
} // namespace esphome::apds9930

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