Compare commits

..

42 Commits

Author SHA1 Message Date
kbx81
70c6021986 Merge branch 'central-netif' into multi-interface-poc
# Conflicts:
#	esphome/components/wifi/wifi_component.cpp
2026-05-23 20:19:32 -05:00
Keith Burzinski
1485675928 Merge branch 'dev' into central-netif 2026-05-23 20:09:33 -05:00
Keith Burzinski
3ed1356bb6 type
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-23 20:08:08 -05:00
J. Nick Koston
d6bc4fea1c Merge branch 'dev' into central-netif 2026-05-23 13:34:04 -05:00
kbx81
f818bddac8 Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-21 21:39:48 -05:00
kbx81
1f0af903ea feat(ethernet): Unit B + B+ — enable_on_boot lifecycle with lazy-init
Brings ethernet to feature parity with wifi's per-interface lifecycle, and
applies the same lazy-init pattern as wifi's Unit B+ so enable_on_boot:false
genuinely reclaims DMA-capable internal SRAM.

Unit B — lifecycle API surface (mirrors WiFiComponent):
- New `enable_on_boot: true` (default) YAML option on ethernet.
- New set_enable_on_boot(), enable(), disable(), is_disabled(), is_enabled()
  methods on EthernetComponent.
- ESP32 path: enable() calls esp_eth_start(), disable() calls esp_eth_stop().
- RP2040 path: stub methods that log a warning — arduino-pico's
  LwipIntfDev doesn't expose a clean start/stop hook; schema parity only.

Unit B+ — lazy-init refactor (mirrors WiFiComponent::wifi_lazy_init_()):
- New ethernet_lazy_init_() method (idempotent, guarded by
  ethernet_initialized_ flag) holds the entire heavy init body that used to
  live in setup(): SPI bus init, netif creation, MAC/PHY allocation, eth
  driver install, netif attach, event handler registration.
- setup() becomes thin: 300ms power-stabilization delay, then if
  enable_on_boot_=true call lazy_init + esp_eth_start, else mark
  disabled_=true and return.
- enable() calls ethernet_lazy_init_() first, then esp_eth_start() — so a
  runtime enable after enable_on_boot:false works end-to-end.

Safe-default getter guards — external callers (sendspin, ethernet_info,
mdns, etc.) may invoke MAC/IP/duplex queries before/regardless of whether
ethernet is enabled. Without guards these call into esp_eth_ioctl(null, ...)
and esp_netif_get_*(null, ...), producing error spam + erroneous
mark_failed() calls during dump_config():
- get_eth_mac_address_raw() falls back to esp_read_mac(ESP_MAC_ETH) — the
  hardware MAC, same value the driver would have returned.
- get_duplex_mode() returns ETH_DUPLEX_HALF.
- get_link_speed() returns ETH_SPEED_10M.
- get_ip_addresses() returns empty (zero) addresses.
- dump_connect_params_() early-returns with "(uninitialized)" log line.

For a user's reboot-to-toggle workflow with both interfaces declared but
only one active per boot: the inactive interface costs zero DMA-capable
memory. WiFi-side reclaims ~15-30KB DMA-capable, ethernet-side reclaims
~3-8KB (W5500 SPI driver is gentler than wifi).

Field-tested on ESP32-S3 + W5500. Verifies clean dump_config() output and
no false "ethernet was marked as failed" state when ethernet is dormant.
2026-05-21 21:38:45 -05:00
kbx81
ed289390df feat(wifi): Unit B+ — defer esp_wifi_init() to lazy-init
WiFi already had enable_on_boot + enable()/disable()/is_disabled() lifecycle,
but enable_on_boot:false didn't actually save any memory. wifi_pre_setup_()
called esp_wifi_init() and esp_netif_create_default_wifi_sta() unconditionally
during setup(), which allocates ~15-30KB of DMA-capable internal SRAM (RX/TX
buffers, driver state, PHY init). The flag only skipped esp_wifi_start() in
the followup branch — the driver was already resident, just not associated.

This commit splits wifi_pre_setup_() into two parts:

- wifi_pre_setup_() (light, kept in setup() always): MAC setup, event group
  creation, WIFI_EVENT/IP_EVENT handler registration. No DMA allocation.

- wifi_lazy_init_() (heavy, NEW): esp_netif_create_default_wifi_sta()/_ap(),
  esp_wifi_init(), esp_wifi_set_storage(). The DMA-allocating calls.
  Guarded by wifi_initialized_ flag for idempotency.

setup() calls wifi_lazy_init_() only when enable_on_boot_=true. The else
branch sets WIFI_COMPONENT_STATE_DISABLED without any heavy init — the
dormant interface costs zero DMA-capable memory.

enable() calls wifi_lazy_init_() before start(), so a runtime enable after
boot-time disable does the heavy init on demand. Idempotent — subsequent
enable/disable cycles don't re-allocate.

disable() is unchanged — it stops wifi but doesn't deinit. A future
"release_on_disable" variant could call esp_wifi_deinit() to actually free
the memory at runtime, but that requires coordinating with consumers
holding wifi-bound sockets and is out of scope here.

ESP-IDF only. Other platforms (Arduino on ESP32, ESP8266) keep the existing
behavior — their wifi_pre_setup_() lives in different per-platform files.

Field-tested on ESP32-S3 with W5500 SPI ethernet + audio + bluetooth_proxy.
Before Unit B+: ~14KB free internal during peak load, crash on W5500 SPI
DMA buffer allocation. After Unit B+: ~32KB free internal, Min Free 78KB
in some test configurations — sufficient headroom for the other DMA
consumers (I2S audio, BT controller) to operate.
2026-05-21 21:32:38 -05:00
kbx81
8f3010ac64 feat(network): Unit A — explicit default-route management
Builds on PR #14012's NetworkComponent + PR #14255's priority list to make
the user's stated interface priority actually drive runtime default-route
selection. Without this, ESP-IDF's auto-selection picks the default netif by
each netif's hardcoded `route_prio` field (WiFi STA = 100, Ethernet = 50,
WiFi AP = 10) — which inverts the user's intent on same-subnet
multi-homing configurations where wifi+ethernet share a broadcast domain.

Changes:

- NetworkComponent gains an IP_EVENT handler registered in setup() that
  re-arbitrates the default netif on every interface up/down. The handler
  walks the priority list in order, picks the highest-priority netif that
  is up, and calls esp_netif_set_default_netif() on it. ESP-IDF then sets
  its internal "manual override" flag so subsequent auto-selection events
  don't undo our choice.
- New StaticVector<NetworkPriorityEntry, 4> stores the priority list with
  zero heap allocation. The interface-name string pointer is a YAML literal
  with static storage duration.
- The timeout_ms field is parsed and stored but not yet consumed by Unit A;
  it's wired up for Unit D (runtime timeout fallback).
- New getters get_active_interface() / get_active_netif() expose the
  currently-active interface for Unit C consumers.
- Python codegen iterates CORE.data[KEY_NETWORK_PRIORITY] and emits
  add_priority_entry() calls per YAML order.

Field-tested on ESP32-S3 with W5500 SPI ethernet + WiFi STA on the same
subnet. The log line "[network] Default interface: <name>" confirms the
arbitration logic fires correctly on IP_EVENT_*_GOT_IP.

Standalone — no schema changes, single-interface configs unaffected.
2026-05-21 21:29:47 -05:00
kbx81
9d9af645ac Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-20 23:52:07 -05:00
Rapsssito
9bfae9e782 Remove redundant esp_netif_init 2026-05-20 09:07:57 +02:00
kbx81
eb64707d94 fix(network): lower NETWORK_PRIORITY_BASE below NetworkComponent's own priority
PR #14255 sets NETWORK_PRIORITY_BASE = 300.0, but PR #14012's
NetworkComponent uses setup_priority::AFTER_BLUETOOTH = 300.0f. When the
highest-priority interface (first in the priority list) tied with
NetworkComponent at 300, the runtime tie-break was determined by
registration order — and NetworkComponent registers AFTER ethernet (its
codegen runs at CoroPriority.NETWORK_SERVICES = 55, below COMMUNICATION
= 60 used by ethernet/wifi).

Result: ethernet's setup() ran before NetworkComponent::setup(),
esp_netif_init() had not yet been called, esp_netif_new() returned
NULL, and EthernetComponent::setup() dereferenced NULL in
esp_netif_attach() — LoadProhibited crash at boot.

Drop the base to 250.0 (matches the historical setup_priority::WIFI /
::ETHERNET default, so a single-entry priority list behaves identically
to a no-priority-block config) and shrink the step to 5.0 to keep all
interfaces in the same priority band, above BEFORE_CONNECTION (220.0)
and below AFTER_BLUETOOTH (300.0).
2026-05-19 23:16:01 -05:00
kbx81
7814e99b6f fix(network): enable USE_SETUP_PRIORITY_OVERRIDE when priority is configured
PR #14255 generates calls to Component::set_setup_priority(float) from
ethernet/wifi to_code(), but that method's body in core/component.cpp is
gated by #ifdef USE_SETUP_PRIORITY_OVERRIDE. Without the define the
declaration exists but no implementation is linked, producing:

  undefined reference to `esphome::Component::set_setup_priority(float)`

The existing convention in cpp_helpers.register_component() is to add
the define whenever CONF_SETUP_PRIORITY appears in a component's YAML.
Mirror that here: when the user declares `network: priority:`, the
priority-driven setup_priority overrides will be emitted, so the define
must be on.
2026-05-19 23:10:32 -05:00
kbx81
8ad6813d44 Merge PR #14255: network priority
Resolves conflicts with PR #14012 (centralized netif init):
- wifi_component_esp_idf.cpp: dropped pr-14255's ESP_ERR_INVALID_STATE
  tolerance hunk (made moot by #14012 removing the call entirely).
- ethernet/__init__.py: kept dev's refactored _to_code_esp32 structure;
  added pr-14255's priority lookup and conditional CONFIG_ESP_WIFI_ENABLED
  gating; preserved dev's top-level import shape.
- network/__init__.py: merged CONF_ID import (#14012) with CONF_PRIORITY
  + CONF_TIMEOUT imports (pr-14255).

Also fixed two latent bugs in pr-14255 where `"wifi" in net_priority`
compared a string against a list of dicts (always False). Replaced with
set comprehension over the normalized interface names.
2026-05-19 22:45:14 -05:00
kbx81
1aa0a489f6 Merge PR #14012: centralize ESP32 network init 2026-05-19 22:37:40 -05:00
Keith Burzinski
be3ccd29f6 Merge branch 'dev' into central-netif 2026-05-19 18:03:15 -05:00
Rodrigo Martín
73b8491936 Update esphome/components/network/network_component.h
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-19 17:50:02 +02:00
Rapsssito
1332ebe729 Exclude from non ESP32 devices 2026-05-19 09:20:08 +02:00
kbx81
028a54422e preen 2026-05-18 18:31:31 -05:00
kbx81
de53e7a6b1 preen 2026-05-18 18:24:54 -05:00
kbx81
0d1d00b654 Merge branch 'dev' into central-netif 2026-05-18 18:19:46 -05:00
pre-commit-ci-lite[bot]
578196ab85 [pre-commit.ci lite] apply automatic fixes 2026-02-28 22:24:08 +00:00
Roy Walker
d9b712ee5f Fix timeout to use ESPhome built-in function. 2026-02-28 16:22:28 -06:00
Roy Walker
1a61cd622e Add support for timeouts before next network connection is turned up and add support for openthread and modem. 2026-02-28 13:41:55 -06:00
pre-commit-ci-lite[bot]
26bdf58daf [pre-commit.ci lite] apply automatic fixes 2026-02-26 17:36:14 +00:00
Roy Walker
c915a2b8f5 Remove duplicate logger and fix import. 2026-02-26 11:34:18 -06:00
Roy Walker
7fdb95c2ef Merge branch 'rwalker777-network-priority' of https://github.com/rwalker777/esphome into rwalker777-network-priority 2026-02-26 11:31:10 -06:00
Roy Walker
e44365abca Remove duplicate CONF_OUTPUT_POWER from import. 2026-02-26 11:30:20 -06:00
rwalker777
532641d523 Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-26 11:30:07 -06:00
rwalker777
bc36892e7d Merge branch 'dev' into rwalker777-network-priority 2026-02-24 13:45:27 -06:00
Roy Walker
3a02c2f8af Fix validation on priority import. 2026-02-24 11:16:21 -06:00
pre-commit-ci-lite[bot]
4a1f9af319 [pre-commit.ci lite] apply automatic fixes 2026-02-24 17:08:56 +00:00
rwalker777
9e29bdfdad Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-24 11:03:34 -06:00
Roy Walker
20c975103b Fix Wifi not connecting with Ethernet config but disconnected. 2026-02-22 20:30:34 -06:00
Roy Walker
549b9f85ae Fix wifi so it doesn't double register. 2026-02-22 19:20:13 -06:00
Roy Walker
0fe2310db4 Fix wifi and ethernet coexisting. 2026-02-22 18:42:35 -06:00
Roy Walker
5af3e5caef Fix stab at network priority support. 2026-02-22 18:22:47 -06:00
Rapsssito
47854ff9de Add missing imports 2026-02-16 13:31:19 +01:00
Rapsssito
8a1ddfb1cc Typo 2026-02-16 13:21:13 +01:00
Rapsssito
cde89212fc Typo 2026-02-16 13:17:00 +01:00
Rapsssito
0a518c1e4c Switch to a network component 2026-02-16 13:13:23 +01:00
Rapsssito
8c7d2d984e Just store if it is initialized 2026-02-16 12:47:14 +01:00
Rapsssito
8390a98614 [ethernet, network, openthread, wifi] centralize esp32 netif intialization 2026-02-16 12:36:22 +01:00
312 changed files with 2315 additions and 7654 deletions

View File

@@ -1 +1 @@
a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f

View File

@@ -29,7 +29,7 @@ Required fields:
- **What does this implement/fix?**: Brief description of changes
- **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.)
- **Related issue**: Use `fixes <link>` syntax if applicable
- **Pull request in esphome.io**: Link if docs are needed
- **Pull request in esphome-docs**: Link if docs are needed
- **Test Environment**: Check platforms you tested on
- **Example config.yaml**: Include working example YAML
- **Checklist**: Verify code is tested and tests added
@@ -54,9 +54,9 @@ Required fields:
- fixes https://github.com/esphome/esphome/issues/XXX
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
- esphome/esphome.io#XXX
- esphome/esphome-docs#XXX
## Test Environment
@@ -83,7 +83,7 @@ component_name:
- [x] Tests have been added to verify that the new code works (under `tests/` folder).
If user exposed functionality or configuration variables are added/changed:
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
- [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
```
## 5. Push and Create PR

View File

@@ -2,7 +2,7 @@
blank_issues_enabled: false
contact_links:
- name: Report an issue with the ESPHome documentation
url: https://github.com/esphome/esphome.io/issues/new/choose
url: https://github.com/esphome/esphome-docs/issues/new/choose
about: Report an issue with the ESPHome documentation.
- name: Report an issue with the ESPHome web server
url: https://github.com/esphome/esphome-webserver/issues/new/choose

View File

@@ -16,9 +16,9 @@
- fixes <link to issue>
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
- esphome/esphome.io#<esphome.io PR number goes here>
- esphome/esphome-docs#<esphome-docs PR number goes here>
## Test Environment
@@ -43,4 +43,4 @@
- [ ] Tests have been added to verify that the new code works (under `tests/` folder).
If user exposed functionality or configuration variables are added/changed:
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
- [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).

View File

@@ -5,7 +5,6 @@ updates:
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
ignore:
# Hypotehsis is only used for testing and is updated quite often
- dependency-name: hypothesis

View File

@@ -35,9 +35,6 @@ module.exports = {
],
DOCS_PR_PATTERNS: [
/https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/,
/esphome\/esphome\.io#\d+/,
// Keep matching the old esphome-docs name during the transition period
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
/esphome\/esphome-docs#\d+/
]

View File

@@ -107,8 +107,6 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/,
];
const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename));
for (const file of addedFiles) {
for (const re of platformPathPatterns) {
const match = file.match(re);
@@ -116,12 +114,6 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
const platform = match[2];
if (!apiData.platformComponents.includes(platform)) break;
// Skip if this is a restructure between flat and subdirectory forms (either direction):
// <component>/<platform>.py <-> <component>/<platform>/__init__.py
const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`;
const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`;
if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break;
labels.add('new-platform');
const content = await fetchPrFileContent(github, context, file);
if (content === null) {

View File

@@ -1,7 +0,0 @@
{
"name": "auto-label-pr",
"private": true,
"scripts": {
"test": "node --test tests/*.test.js"
}
}

View File

@@ -1,147 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { detectNewPlatforms, detectNewComponents } = require('../detectors');
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
// to check for CONFIG_SCHEMA in newly added files.
function makeGithub(content = '') {
return {
rest: {
repos: {
getContent: async () => ({
data: { content: Buffer.from(content).toString('base64') }
})
}
}
};
}
const CONTEXT = {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } }
};
const API_DATA = {
targetPlatforms: ['esp32', 'esp8266', 'rp2040'],
platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve']
};
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// ---------------------------------------------------------------------------
// detectNewPlatforms
// ---------------------------------------------------------------------------
describe('detectNewPlatforms', () => {
describe('restructure detection (no false positives)', () => {
it('flat .py -> subdir __init__.py is not a new platform', async () => {
const prFiles = [
{ filename: 'esphome/components/endstop/cover.py', status: 'removed' },
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
];
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
assert.equal(result.labels.size, 0);
assert.equal(result.hasYamlLoadable, false);
});
it('subdir __init__.py -> flat .py is not a new platform', async () => {
const prFiles = [
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' },
{ filename: 'esphome/components/endstop/cover.py', status: 'added' },
];
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
assert.equal(result.labels.size, 0);
assert.equal(result.hasYamlLoadable, false);
});
});
describe('genuine new platforms', () => {
it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
const prFiles = [
{ filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' },
];
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
assert.ok(result.labels.has('new-platform'));
assert.equal(result.hasYamlLoadable, true);
});
it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
const prFiles = [
{ filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
];
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
assert.ok(result.labels.has('new-platform'));
assert.equal(result.hasYamlLoadable, true);
});
it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => {
const prFiles = [
{ filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
];
const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA);
assert.ok(result.labels.has('new-platform'));
assert.equal(result.hasYamlLoadable, false);
});
it('non-platform file addition produces no labels', async () => {
const prFiles = [
{ filename: 'esphome/components/my_sensor/sensor.py', status: 'added' },
];
// Override platformComponents so 'sensor' is not a recognized platform -> no label expected.
const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] };
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData);
assert.equal(result.labels.size, 0);
assert.equal(result.hasYamlLoadable, false);
});
});
});
// ---------------------------------------------------------------------------
// detectNewComponents
// ---------------------------------------------------------------------------
describe('detectNewComponents', () => {
it('new top-level __init__.py sets new-component', async () => {
const prFiles = [
{ filename: 'esphome/components/actuator/__init__.py', status: 'added', },
];
const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles);
assert.ok(result.labels.has('new-component'));
assert.equal(result.hasYamlLoadable, false);
});
it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => {
const prFiles = [
{ filename: 'esphome/components/my_component/__init__.py', status: 'added' },
];
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
assert.ok(result.labels.has('new-component'));
assert.equal(result.hasYamlLoadable, true);
});
it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => {
const prFiles = [
{ filename: 'esphome/components/my_platform/__init__.py', status: 'added' },
];
const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles);
assert.ok(result.labels.has('new-component'));
assert.ok(result.labels.has('new-target-platform'));
});
it('modified __init__.py does not set new-component', async () => {
const prFiles = [
{ filename: 'esphome/components/existing/__init__.py', status: 'modified' },
];
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
assert.equal(result.labels.size, 0);
});
it('nested __init__.py does not set new-component', async () => {
const prFiles = [
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
];
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
assert.equal(result.labels.size, 0);
});
});

View File

@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token
id: generate-token

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

View File

@@ -42,7 +42,7 @@ jobs:
- "docker"
# - "lint"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View File

@@ -1,27 +0,0 @@
name: CI - GitHub Scripts
on:
push:
branches: [dev, beta, release]
paths:
- ".github/scripts/**"
- ".github/workflows/ci-github-scripts.yml"
pull_request:
paths:
- ".github/scripts/**"
- ".github/workflows/ci-github-scripts.yml"
permissions:
contents: read
jobs:
test-auto-label-pr:
name: Test auto-label-pr scripts
runs-on: ubuntu-latest
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run tests
working-directory: .github/scripts/auto-label-pr
run: npm test

View File

@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo

View File

@@ -28,7 +28,7 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
@@ -74,7 +74,7 @@ jobs:
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -97,7 +97,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -123,7 +123,7 @@ jobs:
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -151,11 +151,11 @@ jobs:
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: esphome/device-builder
ref: main
@@ -221,7 +221,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
@@ -281,7 +281,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -353,7 +353,7 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python 3.13
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -405,7 +405,7 @@ jobs:
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -434,7 +434,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -452,7 +452,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
with:
run: |
. venv/bin/activate
@@ -490,7 +490,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -575,7 +575,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -670,7 +670,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -764,7 +764,7 @@ jobs:
version: 1.0
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -889,7 +889,7 @@ jobs:
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -971,7 +971,7 @@ jobs:
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -997,7 +997,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.base_ref }}
@@ -1179,7 +1179,7 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1248,7 +1248,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:

View File

@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |

View File

@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}

View File

@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:${{matrix.language}}"

View File

@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:

View File

@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -60,7 +60,7 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -92,7 +92,7 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1

View File

@@ -28,10 +28,10 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Checkout Home Assistant
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: home-assistant/core
path: lib/home-assistant

View File

@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.15
rev: v0.15.14
hooks:
# Run the linter.
- id: ruff

View File

@@ -462,7 +462,7 @@ This document provides essential context for AI models interacting with this pro
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
* Documentation is hosted in the separate `esphome/esphome-docs` repository.
* The contribution workflow is the same as for the codebase.
* When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync.
@@ -681,7 +681,7 @@ This document provides essential context for AI models interacting with this pro
- [ ] Explored non-breaking alternatives
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
- [ ] Documented migration path in PR description with before/after examples
- [ ] Updated all internal usage and esphome.io
- [ ] Updated all internal usage and esphome-docs
- [ ] Tested backward compatibility during deprecation period
* **Deprecation Pattern (C++):**

View File

@@ -417,7 +417,6 @@ esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
esphome/components/ring_buffer/* @kahrendt
esphome/components/router/speaker/* @kahrendt
esphome/components/rp2040/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan

View File

@@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
process_stacktrace = module.process_stacktrace
process_stacktrace = getattr(module, "process_stacktrace")
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
@@ -639,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
time_ = datetime.now().astimezone()
time_ = datetime.now()
milliseconds = time_.microsecond // 1000
time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
@@ -695,11 +695,6 @@ def _wrap_to_code(name, comp, yaml_util):
def write_cpp(config: ConfigType) -> int:
from esphome import writer
# Refresh the storage sidecar and clean an incompatible previous build
# before regenerating any sources. This may full-wipe the build dir, so it
# has to run before write_cpp_file writes src/.
writer.update_storage_json()
if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore()
@@ -765,7 +760,6 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
toolchain.get_idedata()
else:
from esphome.platformio import toolchain
@@ -800,7 +794,7 @@ def _check_and_emit_build_info() -> None:
# Read build_info from JSON
try:
with build_info_json_path.open(encoding="utf-8") as f:
with open(build_info_json_path, encoding="utf-8") as f:
build_info = json.load(f)
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Failed to read build_info: %s", e)
@@ -1062,7 +1056,7 @@ def _wait_for_serial_port(
def _port_found() -> bool:
if port is not None:
if os.name == "posix":
return Path(port).exists()
return os.path.exists(port)
return any(p.path == port for p in get_serial_ports())
ports = get_serial_ports()
if known_ports is not None:
@@ -1107,7 +1101,7 @@ def upload_program(
host = devices[0]
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if module.upload_program(config, args, host):
if getattr(module, "upload_program")(config, args, host):
return 0, host
except AttributeError:
pass
@@ -1356,23 +1350,10 @@ def _validate_bootloader_binary(binary: Path) -> None:
)
def _should_subscribe_states(args: ArgsProtocol) -> bool:
"""Determine whether entity state changes should be shown in log output.
The ``--states``/``--no-states`` command line flags take precedence. When
neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls
the behavior, defaulting to showing states.
"""
states = getattr(args, "states", None)
if states is not None:
return states
return get_bool_env("ESPHOME_LOG_STATES", True)
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if module.show_logs(config, args, devices):
if getattr(module, "show_logs")(config, args, devices):
return 0
except AttributeError:
pass
@@ -1398,7 +1379,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
subscribe_states=not getattr(args, "no_states", False),
)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
@@ -1431,47 +1412,17 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
if not CORE.verbose:
config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets)
# add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
output = _redact_with_legacy_fallback(output)
output = re.sub(
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
)
if not CORE.quiet:
safe_print(output)
_LOGGER.info("Configuration is valid!")
return 0
# Legacy substring redaction fallback for unmigrated schemas; removed in
# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
# block; first line is structural). The fragment must either start the
# field name or follow ``_`` so the warning names a real field; this avoids
# false positives like ``monkey:`` matching the ``key`` fragment.
_LEGACY_REDACTION_RE = re.compile(
r"(?P<key>\b(?:\w+_)?(?:password|key|psk|ssid))\: "
r"(?!\\033\[8m|!secret\b|!lambda\b)(?P<val>.+)"
)
_LEGACY_REDACTION_REMOVAL = "2026.12.0"
def _redact_with_legacy_fallback(output: str) -> str:
unmarked: set[str] = set()
def _replace(m: re.Match[str]) -> str:
unmarked.add(m.group("key"))
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
output = _LEGACY_REDACTION_RE.sub(_replace, output)
for key in sorted(unmarked):
_LOGGER.warning(
"Field '%s' is being redacted by a legacy substring heuristic. "
"Mark this field's schema validator with cv.sensitive(...) for "
"deterministic redaction; the heuristic will be removed in %s.",
key,
_LEGACY_REDACTION_REMOVAL,
)
return output
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
# generating code might modify config, so it must be done in order to generate
# a hash that will match what was generated when compiling and then running
@@ -1636,7 +1587,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import writer
try:
writer.clean_build(full=True)
writer.clean_build()
except OSError as err:
_LOGGER.error("Error deleting build files: %s", err)
return 1
@@ -1849,7 +1800,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
ram_report = ram_analyzer.generate_report()
print()
print(ram_report)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
except Exception as e: # pylint: disable=broad-except
_LOGGER.warning("RAM strings analysis failed: %s", e)
return 0
@@ -2037,29 +1988,6 @@ SIMPLE_CONFIG_ACTIONS = [
]
def _add_states_args(parser: argparse.ArgumentParser) -> None:
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable
controls whether entity state changes are shown (defaulting to showing them).
"""
states_group = parser.add_mutually_exclusive_group()
states_group.add_argument(
"--states",
dest="states",
action="store_true",
default=None,
help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).",
)
states_group.add_argument(
"--no-states",
dest="states",
action="store_false",
default=None,
help="Do not show entity state changes in log output.",
)
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -2236,7 +2164,11 @@ def parse_args(argv):
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
_add_states_args(parser_logs)
parser_logs.add_argument(
"--no-states",
action="store_true",
help="Do not show entity state changes in log output.",
)
parser_discover = subparsers.add_parser(
"discover",
@@ -2268,7 +2200,11 @@ def parse_args(argv):
"--no-logs", help="Disable starting logs.", action="store_true"
)
_add_states_args(parser_run)
parser_run.add_argument(
"--no-states",
action="store_true",
help="Do not show entity state changes in log output.",
)
parser_run.add_argument(
"--reset",

View File

@@ -6,7 +6,6 @@ from collections import defaultdict
from collections.abc import Callable
import heapq
from operator import itemgetter
from pathlib import Path
import sys
from typing import TYPE_CHECKING
@@ -510,7 +509,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):"
)
for i, (_symbol, demangled, size) in enumerate(large_core_symbols):
for i, (symbol, demangled, size) in enumerate(large_core_symbols):
# Core symbols only track (symbol, demangled, size) without section info,
# so we don't show section labels here
lines.append(
@@ -602,7 +601,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
)
for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
lines.append(
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
)
@@ -641,7 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
)
for _symbol, demangled, size, section in large_ram_syms[:10]:
for symbol, demangled, size, section in large_ram_syms[:10]:
# Format section label consistently by stripping leading dot
section_label = section.lstrip(".") if section else ""
display_name = _format_pstorage_name(demangled)
@@ -700,7 +699,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
content = "\n".join(lines)
if output_file:
with Path(output_file).open("w", encoding="utf-8") as f:
with open(output_file, "w", encoding="utf-8") as f:
f.write(content)
else:
print(content)
@@ -738,6 +737,7 @@ def main():
# Load build directory
import json
from pathlib import Path
from esphome.platformio.toolchain import IDEData
@@ -785,7 +785,7 @@ def main():
if not idedata_path.exists():
continue
try:
with idedata_path.open(encoding="utf-8") as f:
with open(idedata_path, encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)

View File

@@ -154,7 +154,7 @@ def batch_demangle(
failed_count = 0
for original, stripped, prefix, demangled in zip(
symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
symbols, symbols_stripped, symbols_prefixes, demangled_lines
):
# Add back any prefix that was removed
demangled = _restore_symbol_prefix(prefix, stripped, demangled)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
@@ -36,7 +37,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
Full path to the tool or None if not found
"""
# Get PlatformIO packages directory
platformio_home = Path("~/.platformio/packages").expanduser()
platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
if not platformio_home.exists():
return None

View File

@@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]):
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
except Exception as exc: # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
self.exception = exc

View File

@@ -7,6 +7,7 @@ from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
@@ -23,7 +24,7 @@ def get_available_components() -> list[str] | None:
return None
try:
with project_desc.open(encoding="utf-8") as f:
with open(project_desc, encoding="utf-8") as f:
data = json.load(f)
component_info = data.get("build_component_info", {})
@@ -212,6 +213,11 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def write_project(minimal: bool = False) -> None:
"""Write ESP-IDF project files."""
# Refresh <data_dir>/storage/<name>.yaml.json so the dashboard's
# /info and /downloads endpoints can locate the build (they 404
# otherwise). This mirrors the PlatformIO build-gen path's call
# in build_gen/platformio.py:write_ini().
update_storage_json()
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())

View File

@@ -1,7 +1,7 @@
from esphome.const import __version__
from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
from esphome.writer import find_begin_end
from esphome.writer import find_begin_end, update_storage_json
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============"
@@ -58,6 +58,7 @@ def get_ini_content():
def write_ini(content):
update_storage_json()
path = CORE.relative_build_path("platformio.ini")
if path.is_file():

View File

@@ -412,7 +412,7 @@ class ConfigBundleCreator:
@staticmethod
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
"""Add a BundleFile to the tar archive with deterministic metadata."""
with bf.source.open("rb") as f:
with open(bf.source, "rb") as f:
_add_bytes_to_tar(tar, bf.path, f.read())

View File

@@ -43,7 +43,7 @@ def save_compiled_config(config: ConfigType) -> None:
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
@@ -62,7 +62,7 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
except Exception: # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))

View File

@@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
cv.Optional(CONF_KEY): validate_encryption_key,
}
)

View File

@@ -1169,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
#ifdef USE_TIME_TIMEZONE
if (!value.timezone.empty()) {
// Check if the sender provided pre-parsed timezone data.
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
@@ -1306,9 +1306,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
const std::vector<std::string> empty_wake_words;
resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}

View File

@@ -1,7 +1,6 @@
#include "api_server.h"
#ifdef USE_API
#include <cerrno>
#include <cinttypes>
#include "api_connection.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
@@ -678,7 +677,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
// Schedule automatic cleanup after timeout (client will have given up by then)
// Uses numeric ID overload to avoid heap allocation from str_sprintf
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
ESP_LOGD(TAG, "Action call %u timed out", action_call_id);
this->unregister_active_action_call(action_call_id);
});
@@ -722,7 +721,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message,
@@ -734,7 +733,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES

View File

@@ -101,14 +101,13 @@ async def async_run_logs(
client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
provide_time=False,
)
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_process_stacktrace = module.process_stacktrace
platform_process_stacktrace = getattr(module, "process_stacktrace")
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
@@ -119,7 +118,7 @@ async def async_run_logs(
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now().astimezone()
time_ = datetime.now()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
nanoseconds = time_.microsecond // 1000

View File

@@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
if isinstance(value, str) and value.endswith("%"):
value = percent_to_position(value)
if isinstance(value, str) and value.endswith(("°", "deg")):
if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
return angle_to_position(
value,
min=round(min * POSITION_TO_ANGLE),

View File

@@ -9,12 +9,9 @@ namespace esphome::audio {
static const char *const TAG = "audio.decoder";
static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block,
// before yielding and returning.
static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32;
static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10;
AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
@@ -23,13 +20,11 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
}
esp_err_t AudioDecoder::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
// Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte
// aligned, so no frame alignment is required.
auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_);
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
if (source == nullptr) {
// create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
return ESP_ERR_INVALID_ARG;
return ESP_ERR_NO_MEM;
}
source->set_source(input_ring_buffer);
this->input_buffer_ = std::move(source);
return ESP_OK;
}
@@ -146,7 +141,13 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
uint8_t no_output_iterations = 0;
uint32_t decoding_start = millis();
bool first_loop_iteration = true;
size_t bytes_processed = 0;
size_t bytes_available_before_processing = 0;
while (state == FileDecoderState::MORE_TO_PROCESS) {
// Transfer decoded out
@@ -160,39 +161,45 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
this->playback_ms_ +=
this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_);
}
if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) {
// All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before
// decoding the next batch
return AudioDecoderState::DECODING;
}
} else {
// If paused, block to avoid wasting CPU resources
delay(READ_WRITE_TIMEOUT_MS);
}
if (this->output_transfer_buffer_->available() > 0) {
// Output transfer buffer indicates backpressure, return so caller can handle other events;
// e.g., stop/pause, before trying again
// Verify there is enough space to store more decoded audio and that the function hasn't been running too long
if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) ||
(millis() - decoding_start > DECODING_TIMEOUT_MS)) {
return AudioDecoderState::DECODING;
}
// Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output
// stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this
// loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and
// the return keeps stop/pause responsive.
if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) {
delay(1);
return AudioDecoderState::DECODING;
// Decode more audio
// Never shift the input buffer; every decoder buffers internally and consumes only what it processed.
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
// Less data is available than what was processed in last iteration, so don't attempt to decode.
// This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer
// will shift the remaining data to the start and copy more from the source the next time the decode function is
// called
break;
}
// Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
// processed, so the source does not need to accumulate or stitch chunks across fill() calls.
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
bytes_available_before_processing = this->input_buffer_->available();
const size_t available_before_decode = this->input_buffer_->available();
if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
// Failed to decode in last attempt and there is no new data
if (available_before_decode == 0) {
if ((this->input_buffer_->free() == 0) && first_loop_iteration) {
// The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact
// same data, we can never recover. For const sources this is correct: the entire file is already available, so
// a decode failure is genuine, not a transient out-of-data condition.
state = FileDecoderState::FAILED;
} else {
// Attempt to get more data next time
state = FileDecoderState::IDLE;
}
} else if (this->input_buffer_->available() == 0) {
// No data to decode, attempt to get more data next time
state = FileDecoderState::IDLE;
} else {
@@ -224,6 +231,9 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
}
first_loop_iteration = false;
bytes_processed = bytes_available_before_processing - this->input_buffer_->available();
if (state == FileDecoderState::POTENTIALLY_FAILED) {
++this->potentially_failed_count_;
} else if (state == FileDecoderState::END_OF_FILE) {
@@ -231,16 +241,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
} else if (state == FileDecoderState::FAILED) {
return AudioDecoderState::FAILED;
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
// Reset the failsafe only when the iteration made forward progress: input was consumed or output was
// produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A
// MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it
// toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever.
if ((this->input_buffer_->available() < available_before_decode) ||
(this->output_transfer_buffer_->available() > 0)) {
this->potentially_failed_count_ = 0;
} else {
++this->potentially_failed_count_;
}
this->potentially_failed_count_ = 0;
}
}
return AudioDecoderState::DECODING;

View File

@@ -61,16 +61,15 @@ class AudioDecoder {
*/
public:
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
/// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes.
/// @param input_buffer_size Size of the input transfer buffer in bytes.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
~AudioDecoder() = default;
/// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr.
/// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource.
/// @param input_ring_buffer weak_ptr of the source ring buffer to read from
/// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.

View File

@@ -12,17 +12,16 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size)
: input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) {
this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size);
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
}
esp_err_t AudioResampler::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
// The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring
// buffer (stored here) and the input stream info (set by start()) are available, in either order.
this->source_ring_buffer_ = input_ring_buffer.lock();
if (this->source_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
if (this->input_transfer_buffer_ != nullptr) {
this->input_transfer_buffer_->set_source(input_ring_buffer);
return ESP_OK;
}
return ESP_OK;
return ESP_ERR_NO_MEM;
}
esp_err_t AudioResampler::add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
@@ -48,7 +47,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
this->input_stream_info_ = input_stream_info;
this->output_stream_info_ = output_stream_info;
if (this->output_transfer_buffer_ == nullptr) {
if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
return ESP_ERR_NO_MEM;
}
@@ -57,13 +56,6 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
return ESP_ERR_NOT_SUPPORTED;
}
// Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks
// the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast.
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
return ESP_ERR_NOT_SUPPORTED;
}
if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) ||
(input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) {
this->resampler_ = make_unique<esp_audio_libs::resampler::Resampler>(
@@ -95,27 +87,8 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
}
AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) {
if (this->audio_source_ == nullptr) {
// Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are
// never split across the ring buffer's wrap boundary.
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
// Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would
// truncate it and could yield a source that tears frames.
return AudioResamplerState::FAILED;
}
// Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our
// reference only after the source has taken ownership.
this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_,
static_cast<uint8_t>(bytes_per_frame));
if (this->audio_source_ == nullptr) {
return AudioResamplerState::FAILED;
}
this->source_ring_buffer_.reset();
}
if (stop_gracefully) {
if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
return AudioResamplerState::FINISHED;
}
}
@@ -129,11 +102,9 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
delay(READ_WRITE_TIMEOUT_MS);
}
// Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
// (there is no intermediate transfer buffer to compact).
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
if (this->audio_source_->available() == 0) {
if (this->input_transfer_buffer_->available() == 0) {
// No samples available to process
return AudioResamplerState::RESAMPLING;
}
@@ -141,17 +112,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_free = this->output_transfer_buffer_->free();
const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free);
const size_t bytes_available = this->audio_source_->available();
const size_t bytes_available = this->input_transfer_buffer_->available();
const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available);
if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) ||
(this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) {
// Adjust gain by -3 dB to avoid clipping due to the resampling process
esp_audio_libs::resampler::ResamplerResults results =
this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
frames_available, frames_free, -3);
this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(),
this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used));
this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
this->output_transfer_buffer_->increase_buffer_length(
this->output_stream_info_.frames_to_bytes(results.frames_generated));
@@ -175,10 +146,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free),
this->input_stream_info_.frames_to_bytes(frames_available));
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
bytes_to_transfer);
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(),
(void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
this->audio_source_->consume(bytes_to_transfer);
this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer);
}

View File

@@ -22,7 +22,7 @@ namespace esphome::audio {
enum class AudioResamplerState : uint8_t {
RESAMPLING, // More data is available to resample
FINISHED, // All file data has been resampled and transferred
FAILED, // Failed to allocate the audio source
FAILED, // Unused state included for consistency among Audio classes
};
class AudioResampler {
@@ -32,16 +32,14 @@ class AudioResampler {
* component). Also supports converting bits per sample.
*/
public:
/// @brief Allocates the output transfer buffer. The input source is created later in resample().
/// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source.
/// @brief Allocates the input and output transfer buffers
/// @param input_buffer_size Size of the input transfer buffer in bytes.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
/// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy
/// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first
/// resample() call, so add_source() and start() may be called in any order.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership
/// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive
/// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
@@ -80,8 +78,7 @@ class AudioResampler {
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
protected:
std::shared_ptr<ring_buffer::RingBuffer> source_ring_buffer_;
std::unique_ptr<RingBufferAudioSource> audio_source_;
std::unique_ptr<AudioSourceTransferBuffer> input_transfer_buffer_;
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
size_t input_buffer_size_;

View File

@@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType:
def _validate_file_shorthand(value: str) -> ConfigType:
value = cv.string_strict(value)
if value.startswith(("http://", "https://")):
if value.startswith("http://") or value.startswith("https://"):
return _file_schema(
{
CONF_TYPE: TYPE_WEB,
@@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
else:
raise cv.Invalid("Unsupported file source")
with path.open("rb") as f:
with open(path, "rb") as f:
data = f.read()
try:

View File

@@ -1,5 +1,7 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
from esphome.components import audio, esp32, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
@@ -19,13 +21,19 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _validate_task_stack_in_psram(value: Any) -> bool:
if value := cv.boolean(value):
return cv.requires_component(psram.DOMAIN)(value)
return value
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioFileMediaSource,
)
.extend(
{
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -41,4 +49,6 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)

View File

@@ -1,5 +1,7 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
from esphome.components import audio, esp32, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
@@ -18,6 +20,14 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _validate_task_stack_in_psram(value: Any) -> bool:
# Only require the psram component when actually enabling PSRAM stacks; validating
# the boolean first means `false` doesn't trigger the requires_component check.
if value := cv.boolean(value):
return cv.requires_component(psram.DOMAIN)(value)
return value
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
@@ -27,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
min=5000, max=1000000
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -43,5 +53,7 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))

View File

@@ -169,7 +169,7 @@ async def to_code_base(config):
path = _compute_local_file_path(_compute_url(config))
try:
with path.open(encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
bsec2_iaq_config = f.read()
except Exception as e:
raise core.EsphomeError(

View File

@@ -1,8 +1,5 @@
import esphome.codegen as cg
from esphome.components.esp32 import (
add_idf_component,
require_libc_picolibc_newlib_compat,
)
from esphome.components.esp32 import add_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE
from esphome.types import ConfigType
@@ -54,8 +51,6 @@ async def to_code(config: ConfigType) -> None:
cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE]))
if config[CONF_TYPE] == ESP32_CAMERA_ENCODER:
add_idf_component(name="espressif/esp32-camera", ref="2.1.5")
# esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream
require_libc_picolibc_newlib_compat()
cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER")
var = cg.new_Pvariable(
config[CONF_ID],

View File

@@ -22,7 +22,6 @@ CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROWS = "rows"
CONF_SHA256 = "sha256"
CONF_STOP_BITS = "stop_bits"
CONF_USE_PSRAM = "use_psram"
CONF_VOLUME_INCREMENT = "volume_increment"

View File

@@ -1,5 +1,3 @@
// DNM: do not merge. Trivial touch to mark daikin changed; this is the working
// esp8266-ard component that should generate memory impact (see PR #16788).
#include "daikin.h"
#include "esphome/components/remote_base/remote_base.h"

View File

@@ -3,18 +3,11 @@ from dataclasses import dataclass
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_DRAW_ROUNDING,
KEY_METADATA,
)
from esphome.components.const import KEY_METADATA
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
CONF_LAMBDA,
CONF_PAGE_ID,
@@ -23,11 +16,10 @@ from esphome.const import (
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.final_validate import full_config
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj
DOMAIN = "display"
IS_PLATFORM_COMPONENT = True
@@ -167,97 +159,29 @@ async def setup_display_core_(var, config):
class DisplayMetaData:
width: int = 0
height: int = 0
has_hardware_rotation: bool = False
byte_order: str = BYTE_ORDER_BIG
has_writer: bool = False
rotation: int = 0
draw_rounding: int = 0
def _get_metadata_list() -> list[tuple]:
"""Get the raw metadata list. Each entry is (id, DisplayMetaData)."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, [])
has_hardware_rotation: bool = False
def get_all_display_metadata() -> dict[str, DisplayMetaData]:
"""Get all display metadata as a dict keyed by resolved ID strings.
Must not be called before IDs have been finalised.
"""
entries = _get_metadata_list()
assert all(id_.id is not None for id_, _ in entries), (
"get_all_display_metadata called before display IDs have been resolved"
)
return {id_.id: meta for id_, meta in entries}
"""Get all display metadata."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
def get_display_metadata(display_id: ID) -> DisplayMetaData:
"""Get display metadata by ID object
Must not be called before IDs have been finalised.
"""
for id_, meta in _get_metadata_list():
if id_ is display_id:
return meta
assert id_.id is not None, (
"get_display_metadata called before display IDs have been resolved"
)
if id_.id == display_id.id:
return meta
# No metadata found, display driver may not yet support it.
# Read the raw config to populate the returned data
global_config = full_config.get()
path = global_config.get_path_for_id(display_id)[:-1]
disp_config = global_config.get_config_for_path(path)
dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0))
if isinstance(dimensions, dict):
dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0))
elif not isinstance(dimensions, tuple) or len(dimensions) != 2:
dimensions = (0, 0)
meta = DisplayMetaData(
width=dimensions[0],
height=dimensions[1],
has_hardware_rotation=False,
byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED),
has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True
or disp_config.get(CONF_PAGES) is not None
or disp_config.get(CONF_LAMBDA) is not None
or disp_config.get(CONF_SHOW_TEST_CARD) is True,
rotation=disp_config.get(CONF_ROTATION, 0),
draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0),
)
_get_metadata_list().append((display_id, meta))
return meta
def get_display_metadata(display_id: str) -> DisplayMetaData | None:
"""Get display metadata by ID for use by other components."""
return get_all_display_metadata().get(display_id, DisplayMetaData())
def add_metadata(
id: ID,
id: str | MockObj,
width: int,
height: int,
has_writer: bool,
has_hardware_rotation: bool = False,
byte_order: str = BYTE_ORDER_BIG,
has_writer: bool = False,
rotation: int = 0,
draw_rounding: int = 0,
):
entries = _get_metadata_list()
assert not any(existing_id is id for existing_id, _ in entries), (
f"Duplicate display metadata for ID {id}"
)
entries.append(
(
id,
DisplayMetaData(
width=width,
height=height,
has_hardware_rotation=has_hardware_rotation,
byte_order=byte_order,
has_writer=has_writer,
rotation=rotation,
draw_rounding=draw_rounding,
),
)
get_all_display_metadata()[str(id)] = DisplayMetaData(
width, height, has_writer, has_hardware_rotation
)

View File

@@ -87,7 +87,7 @@ async def to_code(config):
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
cg.add_library("esphome/dsmr_parser", "1.8.0")
cg.add_library("esphome/dsmr_parser", "1.4.0")
def final_validate(config: ConfigType) -> ConfigType:

View File

@@ -153,9 +153,8 @@ void Dsmr::receive_encrypted_telegram_() {
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
this->stop_requesting_data_();
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size());
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.full_content().size()),
telegram.full_content().data());
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.content().size()), telegram.content().data());
MyData data;
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
@@ -168,7 +167,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram)
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
if (this->s_telegram_ != nullptr) {
this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size());
this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
}
return true;
}

View File

@@ -16,14 +16,9 @@
#include <span>
#include <vector>
// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can
// still be in scope when a sibling component (e.g. wireguard) pulls in
// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the
// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to
// avoid that linker error.
#if __has_include(<psa/crypto.h>)
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
#elif __has_include(<mbedtls/gcm.h>)
#if __has_include(<mbedtls/esp_config.h>)
#include <mbedtls/esp_config.h>
#endif
@@ -38,7 +33,7 @@ namespace esphome::dsmr {
#if __has_include(<psa/crypto.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
#elif __has_include(<mbedtls/gcm.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
#else
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
@@ -74,8 +69,7 @@ class Dsmr : public Component, public uart::UARTDevice {
receive_timeout_(receive_timeout),
request_pin_(request_pin),
buffer_(max_telegram_length),
packet_accumulator_(buffer_, crc_check),
dlms_decryptor_(gcm_decryptor_, crc_check) {
packet_accumulator_(buffer_, crc_check) {
this->set_decryption_key_(decryption_key);
}
@@ -98,11 +92,7 @@ class Dsmr : public Component, public uart::UARTDevice {
// Remove before 2026.8.0
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
void set_decryption_key(const std::string &decryption_key) {
// Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols,
// otherwise `Aes128GcmDecryptionKey::from_hex` will fail.
this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str());
}
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
// Sensor setters
#define DSMR_SET_SENSOR(s) \
@@ -148,7 +138,7 @@ class Dsmr : public Component, public uart::UARTDevice {
std::vector<uint8_t> buffer_;
dsmr_parser::PacketAccumulator packet_accumulator_;
Aes128GcmDecryptorImpl gcm_decryptor_;
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_;
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
std::array<uint8_t, 256> uart_chunk_reading_buf_;
};
} // namespace esphome::dsmr

View File

@@ -248,6 +248,10 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_switch_position"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_failures"): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
@@ -804,10 +808,6 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_switch_position"): cv.invalid(
"'electricity_switch_position' has moved to the 'text_sensor' platform."
"Move it under 'text_sensor' to fix."
),
}
).extend(cv.COMPONENT_SCHEMA)

View File

@@ -14,7 +14,6 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("p1_version"): text_sensor.text_sensor_schema(),
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),

View File

@@ -52,8 +52,6 @@ class E131Component : public esphome::Component {
if (!this->udp_.parsePacket())
return -1;
return this->udp_.read(buf, len);
#else
return -1;
#endif
}
bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet);

View File

@@ -46,7 +46,7 @@ from esphome.const import (
Toolchain,
__version__,
)
from esphome.core import CORE, EsphomeError, HexInt, Library
from esphome.core import CORE, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
@@ -56,7 +56,7 @@ from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
from .boards import BOARDS, STANDARD_BOARDS
from .const import (
from .const import ( # noqa
KEY_ARDUINO_LIBRARIES,
KEY_BOARD,
KEY_COMPONENTS,
@@ -78,18 +78,15 @@ from .const import (
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
VARIANT_FRIENDLY,
VARIANTS,
)
# force import gpio to register pin schema
from .gpio import esp32_pin_to_code # noqa: F401
from .gpio import esp32_pin_to_code # noqa
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["preferences"]
@@ -406,12 +403,9 @@ CPU_FREQUENCIES = {
VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160),
VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160),
VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96),
VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96),
VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96),
VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400),
VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240),
VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240),
VARIANT_ESP32S31: get_cpu_frequencies(240, 320),
}
# Make sure not missed here if a new variant added.
@@ -470,20 +464,21 @@ def set_core_data(config):
framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION])
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver
# Store the underlying IDF version for framework-agnostic checks.
# Store the underlying IDF version for framework-agnostic checks
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
idf_ver = framework_ver
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None:
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None:
if CORE.using_toolchain_esp_idf:
# Official ESP-IDF frameworks don't use extra
idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch)
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver
else:
raise cv.Invalid(
f"Arduino version {framework_ver} has no known ESP-IDF version mapping. "
"Please update ARDUINO_IDF_VERSION_LOOKUP.",
path=[CONF_FRAMEWORK, CONF_VERSION],
)
# The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does.
if CORE.using_toolchain_esp_idf:
idf_ver = _strip_pioarduino_revision(idf_ver)
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver
CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD]
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE]
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
@@ -720,9 +715,6 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"dev": cv.Version(3, 3, 8),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(
4, 0, 0, "alpha1"
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
@@ -743,7 +735,6 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# These versions correspond to pioarduino/esp-idf releases
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
cv.Version(3, 3, 6): cv.Version(5, 5, 2),
@@ -838,16 +829,6 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
return version
def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version:
"""Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1").
Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept.
"""
if ver.extra.isdigit():
return cv.Version(ver.major, ver.minor, ver.patch)
return ver
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
@@ -916,10 +897,8 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
"If there are connectivity or build issues please remove the manual source."
)
# esp-idf framework only: drop pioarduino's packaging revision (config + download).
# Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key).
if value[CONF_TYPE] == FRAMEWORK_ESP_IDF:
value[CONF_VERSION] = str(_strip_pioarduino_revision(version))
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
return config
@@ -928,16 +907,11 @@ def _validate_toolchain(value) -> Toolchain:
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
def _resolve_toolchain(value: ConfigType) -> ConfigType:
def _check_versions(config):
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
# Runs before _detect_variant so downstream validators can rely on
# CORE.toolchain instead of re-resolving it from the config dict.
if CORE.toolchain is None:
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
return value
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
def _check_versions(config: ConfigType) -> ConfigType:
if CORE.using_toolchain_esp_idf:
return _check_esp_idf_versions(config)
return _check_pio_versions(config)
@@ -959,21 +933,7 @@ def _detect_variant(value):
variant = value.get(CONF_VARIANT)
if variant and board is None:
# If variant is set, we can derive the board from it
# variant has already been validated against the known set.
# PlatformIO needs a real board name to find its board file; the
# ESP-IDF toolchain only uses CONF_BOARD as the informational
# ESPHOME_BOARD string, so synthesize one from the friendly variant
# name rather than carrying a PIO board name through the IDF build.
if CORE.using_toolchain_esp_idf:
value = value.copy()
value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower()
return value
if variant not in STANDARD_BOARDS:
raise cv.Invalid(
f"No default board is known for {variant}. "
f"Please specify the `board:` option explicitly.",
path=[CONF_VARIANT],
)
# variant has already been validated against the known set
value = value.copy()
value[CONF_BOARD] = STANDARD_BOARDS[variant]
if variant == VARIANT_ESP32P4:
@@ -1260,7 +1220,6 @@ KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required"
def require_vfs_select() -> None:
@@ -1369,15 +1328,6 @@ def require_adc_oneshot_iram() -> None:
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
def require_libc_picolibc_newlib_compat() -> None:
"""Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+.
Call this from components that link against precompiled Newlib binaries
referencing types/symbols the shim provides (e.g. esp32-camera).
"""
CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
# Match operator followed by version-like string (digit or *)
@@ -1656,7 +1606,6 @@ CONFIG_SCHEMA = cv.All(
),
}
),
_resolve_toolchain,
_detect_variant,
_set_default_framework,
_check_versions,
@@ -1783,26 +1732,6 @@ async def _write_arduino_libraries_sdkconfig() -> None:
add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs)
@coroutine_with_priority(CoroPriority.FINAL)
async def _set_libc_picolibc_newlib_compat() -> None:
"""Apply the PicolibC Newlib compatibility shim option on IDF 6.0+.
IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default.
Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call
(default priority) is seen before the option is written. A user-supplied
sdkconfig_options value takes precedence.
"""
if idf_version() < cv.Version(6, 0, 0):
return
option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY"
if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
return
add_idf_sdkconfig_option(
option,
CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False),
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_yaml_idf_components(components: list[ConfigType]):
"""Add IDF components from YAML config with final priority to override code-added components."""
@@ -1887,11 +1816,8 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
# Demote IDF's blanket -Werror to warnings so third-party libs
# and user lambdas don't need a -Wno-error=<class> per warning.
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
# can't be globally undone); -Wno-error then handles the demotion.
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
# Undo IDF's blanket -Werror so third-party libraries and user
# lambdas don't need a -Wno-error=<class> entry per warning class.
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
@@ -2336,8 +2262,17 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False)
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
CORE.add_job(_set_libc_picolibc_newlib_compat)
# Disable PicolibC Newlib compatibility shim on IDF 6.0+
# IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local
# stdin/stdout/stderr and getreent() for code compiled against Newlib.
# ESPHome doesn't link against Newlib-built libraries that use stdio.
# If a component needs it (e.g. precompiled Newlib binaries), re-enable via:
# esp32:
# framework:
# sdkconfig_options:
# CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y"
if idf_version() >= cv.Version(6, 0, 0):
add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False)
# Disable regi2c control functions in IRAM
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
@@ -2645,26 +2580,6 @@ def _write_idf_component_yml():
"override_path": str(stub_path),
}
# On the PlatformIO toolchain, framework-arduinoespressif32 already
# ships arduino-esp32. Stub the managed component so anything that
# `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a
# CMake target that re-exports the framework's INTERFACE properties
# (INCLUDE_DIRS, public compile options like -DESP32, transitive
# REQUIRES) instead of triggering a duplicate download/rebuild.
if CORE.using_toolchain_platformio:
arduino_stub = stubs_dir / "arduino-esp32"
arduino_stub.mkdir(exist_ok=True)
write_file_if_changed(
arduino_stub / "CMakeLists.txt",
"idf_component_register()\n"
"target_link_libraries(${COMPONENT_LIB} "
f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n",
)
dependencies[ARDUINO_ESP32_COMPONENT_NAME] = {
"version": "*",
"override_path": str(arduino_stub),
}
# Remove stubs for components that are now required by enabled libraries
for component_name in required_idf_components:
stub_path = stubs_dir / _idf_component_stub_name(component_name)
@@ -2740,32 +2655,16 @@ def copy_files():
def _decode_pc(config, addr):
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
from esphome.platformio import toolchain
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
addr2line_path = idedata.addr2line_path
firmware_elf_path = idedata.firmware_elf_path
if not addr2line_path or not firmware_elf_path:
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # noqa: BLE001 # pylint: disable=broad-except
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return

View File

@@ -9,6 +9,7 @@ from .const import (
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANTS,
)
STANDARD_BOARDS = {
@@ -24,6 +25,9 @@ STANDARD_BOARDS = {
VARIANT_ESP32S3: "esp32-s3-devkitc-1",
}
# Make sure not missed here if a new variant added.
assert all(v in STANDARD_BOARDS for v in VARIANTS)
ESP32_BASE_PINS = {
"TX": 1,
"RX": 3,

View File

@@ -24,12 +24,9 @@ VARIANT_ESP32C5 = "ESP32C5"
VARIANT_ESP32C6 = "ESP32C6"
VARIANT_ESP32C61 = "ESP32C61"
VARIANT_ESP32H2 = "ESP32H2"
VARIANT_ESP32H4 = "ESP32H4"
VARIANT_ESP32H21 = "ESP32H21"
VARIANT_ESP32P4 = "ESP32P4"
VARIANT_ESP32S2 = "ESP32S2"
VARIANT_ESP32S3 = "ESP32S3"
VARIANT_ESP32S31 = "ESP32S31"
VARIANTS = [
VARIANT_ESP32,
VARIANT_ESP32C2,
@@ -38,12 +35,9 @@ VARIANTS = [
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
]
VARIANT_FRIENDLY = {
@@ -54,12 +48,9 @@ VARIANT_FRIENDLY = {
VARIANT_ESP32C6: "ESP32-C6",
VARIANT_ESP32C61: "ESP32-C61",
VARIANT_ESP32H2: "ESP32-H2",
VARIANT_ESP32H4: "ESP32-H4",
VARIANT_ESP32H21: "ESP32-H21",
VARIANT_ESP32P4: "ESP32-P4",
VARIANT_ESP32S2: "ESP32-S2",
VARIANT_ESP32S3: "ESP32-S3",
VARIANT_ESP32S31: "ESP32-S31",
}
esp32_ns = cg.esphome_ns.namespace("esp32")

View File

@@ -31,12 +31,9 @@ from .const import (
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
esp32_ns,
)
from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports
@@ -46,12 +43,9 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support
from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports
from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports
from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports
from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports
from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports
from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports
from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports
from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports
from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports
ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin)
@@ -126,14 +120,6 @@ _esp32_validations = {
pin_validation=esp32_h2_validate_gpio_pin,
usage_validation=esp32_h2_validate_supports,
),
VARIANT_ESP32H4: ESP32ValidationFunctions(
pin_validation=esp32_h4_validate_gpio_pin,
usage_validation=esp32_h4_validate_supports,
),
VARIANT_ESP32H21: ESP32ValidationFunctions(
pin_validation=esp32_h21_validate_gpio_pin,
usage_validation=esp32_h21_validate_supports,
),
VARIANT_ESP32P4: ESP32ValidationFunctions(
pin_validation=esp32_p4_validate_gpio_pin,
usage_validation=esp32_p4_validate_supports,
@@ -146,10 +132,6 @@ _esp32_validations = {
pin_validation=esp32_s3_validate_gpio_pin,
usage_validation=esp32_s3_validate_supports,
),
VARIANT_ESP32S31: ESP32ValidationFunctions(
pin_validation=esp32_s31_validate_gpio_pin,
usage_validation=esp32_s31_validate_supports,
),
}

View File

@@ -1,34 +0,0 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
from esphome.pins import check_strapping_pin
# Partial set from the ESP-IDF / esptool boot-mode docs:
# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html
# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section.
_ESP32H21_STRAPPING_PINS: set[int] = {13, 14}
_LOGGER = logging.getLogger(__name__)
def esp32_h21_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 25:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)")
return value
def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
if num < 0 or num > 25:
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)")
if is_input:
# All ESP32 pins support input mode
pass
check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER)
return value

View File

@@ -1,34 +0,0 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
from esphome.pins import check_strapping_pin
# Partial set from the ESP-IDF / esptool boot-mode docs:
# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html
# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section.
_ESP32H4_STRAPPING_PINS: set[int] = {13, 14}
_LOGGER = logging.getLogger(__name__)
def esp32_h4_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 39:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)")
return value
def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
if num < 0 or num > 39:
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)")
if is_input:
# All ESP32 pins support input mode
pass
check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER)
return value

View File

@@ -1,38 +0,0 @@
import logging
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
from esphome.pins import check_strapping_pin
# Per the ESP32-S31 datasheet (page 96):
# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf
_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33}
_ESP32S31_STRAPPING_PINS: set[int] = {60, 61}
_LOGGER = logging.getLogger(__name__)
def esp32_s31_validate_gpio_pin(value: int) -> int:
if value < 0 or value > 61:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)")
if value in _ESP32S31_SPI_FLASH_PINS:
raise cv.Invalid(
f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used."
)
return value
def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
if num < 0 or num > 61:
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)")
if is_input:
# All ESP32 pins support input mode
pass
check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER)
return value

View File

@@ -62,26 +62,6 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29
MODEL_CHARACTERISTIC_UUID = 0x2A24
FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26
# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit.
_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
def uuid_is(uuid: int | str, uuid16: int) -> bool:
"""Return True if a validated UUID refers to the given 16-bit short UUID.
A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an
uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every
representation of the same UUID must be considered equivalent.
"""
if isinstance(uuid, int):
return uuid == uuid16
return uuid.upper() in (
f"{uuid16:04X}",
f"{uuid16:08X}",
f"{uuid16:08X}{_BASE_UUID_SUFFIX}",
)
# Core key to store the global configuration
KEY_NOTIFY_REQUIRED = "notify_required"
KEY_SET_VALUE = "set_value"
@@ -215,7 +195,7 @@ def create_description_cud(char_config):
return char_config
# If the config displays a description, there cannot be a descriptor with the CUD UUID
for desc in char_config[CONF_DESCRIPTORS]:
if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID):
if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID:
raise cv.Invalid(
f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present"
)
@@ -238,7 +218,7 @@ def create_notify_cccd(char_config):
return char_config
# If the CCCD descriptor is already present, return the config
for desc in char_config[CONF_DESCRIPTORS]:
if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID):
if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID:
# Check if the WRITE property is set
if not desc[CONF_WRITE]:
raise cv.Invalid(
@@ -264,7 +244,7 @@ def create_device_information_service(config):
# If there is already a device information service,
# there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties
for service in config[CONF_SERVICES]:
if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
if (
CONF_MODEL in config
or CONF_MANUFACTURER in config
@@ -612,7 +592,7 @@ async def to_code(config):
)
for char_conf in service_config[CONF_CHARACTERISTICS]:
await to_code_characteristic(service_var, char_conf)
if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
cg.add(var.set_device_information_service(service_var))
else:
cg.add(var.enqueue_start_service(service_var))

View File

@@ -3,11 +3,7 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
require_libc_picolibc_newlib_compat,
)
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
from esphome.components.psram import DOMAIN as psram_domain
import esphome.config_validation as cv
from esphome.const import (
@@ -406,8 +402,6 @@ async def to_code(config):
add_idf_component(name="espressif/esp32-camera", ref="2.1.5")
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True)
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False)
# esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream
require_libc_picolibc_newlib_compat()
for conf in config.get(CONF_ON_STREAM_START, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)

View File

@@ -3,7 +3,6 @@ from pathlib import Path
from esphome import pins
from esphome.components import esp32
from esphome.components.const import CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
@@ -40,7 +39,6 @@ BASE_SCHEMA = cv.Schema(
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
}
)
@@ -244,12 +242,6 @@ async def to_code(config):
else:
_configure_spi(config)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
# Library versions
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
@@ -257,7 +249,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -3,7 +3,6 @@ from typing import Any
import esphome.codegen as cg
from esphome.components import esp32, update
from esphome.components.const import CONF_SHA256
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE
from esphome.core import CORE, ID, HexInt
@@ -12,6 +11,7 @@ CODEOWNERS = ["@swoboda1337"]
AUTO_LOAD = ["sha256", "watchdog", "json"]
DEPENDENCIES = ["esp32_hosted"]
CONF_SHA256 = "sha256"
CONF_HTTP_REQUEST_ID = "http_request_id"
TYPE_EMBEDDED = "embedded"
@@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None:
return
path = CORE.relative_config_path(config[CONF_PATH])
with path.open("rb") as f:
with open(path, "rb") as f:
firmware_data = f.read()
calculated = hashlib.sha256(firmware_data).hexdigest()
expected = config[CONF_SHA256].lower()
@@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None:
if config[CONF_TYPE] == TYPE_EMBEDDED:
path = config[CONF_PATH]
with CORE.relative_config_path(path).open("rb") as f:
with open(CORE.relative_config_path(path), "rb") as f:
firmware_data = f.read()
rhs = [HexInt(x) for x in firmware_data]
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)

View File

@@ -472,7 +472,7 @@ def _decode_pc(config, addr):
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # noqa: BLE001 # pylint: disable=broad-except
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return

View File

@@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All(
host=8082,
): cv.port,
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_PASSWORD): cv.sensitive(),
cv.Optional(CONF_PASSWORD): cv.string,
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
),

View File

@@ -2,9 +2,12 @@ from dataclasses import dataclass
import logging
from esphome import automation, pins
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.network import ip_address_literal
from esphome.components.network import (
KEY_NETWORK_PRIORITY,
get_network_priority,
ip_address_literal,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -29,6 +32,7 @@ from esphome.const import (
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
CONF_PRIORITY,
CONF_RESET_PIN,
CONF_SPI,
CONF_STATIC_IP,
@@ -50,7 +54,6 @@ from esphome.core import (
import esphome.final_validate as fv
from esphome.types import ConfigType
CONFLICTS_WITH = ["wifi"]
AUTO_LOAD = ["network"]
LOGGER = logging.getLogger(__name__)
@@ -165,7 +168,7 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = {
"KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"),
"KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"),
"W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"),
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"),
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"),
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
}
@@ -219,10 +222,6 @@ MANUAL_IP_SCHEMA = cv.Schema(
EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component)
ManualIP = ethernet_ns.struct("ManualIP")
EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition)
EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition)
EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action)
EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action)
def _is_framework_spi_polling_mode_supported() -> bool:
@@ -494,6 +493,11 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Apply network priority if configured, otherwise use the existing default
prio = get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
if CORE.is_esp32:
await _to_code_esp32(var, config)
elif CORE.is_rp2040:
@@ -586,10 +590,16 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
)
cg.add(var.add_phy_register(reg))
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Disable WiFi when using Ethernet alone to save memory.
# When network: priority: lists both interfaces, WiFi must remain enabled.
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
if not running_with_wifi:
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
@@ -675,6 +685,17 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
full = fv.full_config.get()
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
if "wifi" in full and not has_priority_config:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config
@@ -725,21 +746,3 @@ def _filter_source_files() -> list[str]:
FILTER_SOURCE_FILES = _filter_source_files
async def _new_pvariable_to_code(config, id_, template_arg, args):
return cg.new_Pvariable(id_, template_arg)
for _name, _cls in (
("ethernet.connected", EthernetConnectedCondition),
("ethernet.enabled", EthernetEnabledCondition),
):
automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code)
for _name, _cls in (
("ethernet.enable", EthernetEnableAction),
("ethernet.disable", EthernetDisableAction),
):
automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)(
_new_pvariable_to_code
)

View File

@@ -1,30 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ETHERNET
#include "ethernet_component.h"
namespace esphome::ethernet {
template<typename... Ts> class EthernetConnectedCondition : public Condition<Ts...> {
public:
bool check(const Ts &...x) override { return global_eth_component->is_connected(); }
};
template<typename... Ts> class EthernetEnabledCondition : public Condition<Ts...> {
public:
bool check(const Ts &...x) override { return global_eth_component->is_enabled(); }
};
template<typename... Ts> class EthernetEnableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_eth_component->enable(); }
};
template<typename... Ts> class EthernetDisableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_eth_component->disable(); }
};
} // namespace esphome::ethernet
#endif

View File

@@ -833,13 +833,10 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
if (!this->ethernet_initialized_) {
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
// of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC.
if (this->fixed_mac_.has_value()) {
memcpy(mac, this->fixed_mac_->data(), 6);
} else {
esp_read_mac(mac, ESP_MAC_ETH);
}
// External callers (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
// assigned to the ETH interface — same value the driver would have returned.
esp_read_mac(mac, ESP_MAC_ETH);
return;
}
esp_err_t err;

View File

@@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None:
elif conf[CONF_TYPE] == TYPE_LOCAL:
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
else:
raise NotImplementedError
raise NotImplementedError()
if config[CONF_COMPONENTS] == "all":
num_components = len(list(components_dir.glob("*/__init__.py")))

View File

@@ -401,7 +401,7 @@ def validate_file_shorthand(value):
data[CONF_WEIGHT] = weight[1:]
return font_file_schema(data)
if value.startswith(("http://", "https://")):
if value.startswith("http://") or value.startswith("https://"):
return font_file_schema(
{
CONF_TYPE: TYPE_WEB,
@@ -563,13 +563,13 @@ async def to_code(config):
point_set.update(flatten(config[CONF_GLYPHS]))
# Create the codepoint to font file map
base_font = FONT_CACHE[config[CONF_FILE]]
point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font)
point_font_map: dict[str, Face] = {c: base_font for c in point_set}
# process extras, updating the map and extending the codepoint list
for extra in config[CONF_EXTRAS]:
extra_points = flatten(extra[CONF_GLYPHS])
point_set.update(extra_points)
extra_font = FONT_CACHE[extra[CONF_FILE]]
point_font_map.update(dict.fromkeys(extra_points, extra_font))
point_font_map.update({c: extra_font for c in extra_points})
codepoints = list(point_set)
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
@@ -594,9 +594,7 @@ async def to_code(config):
x.height,
]
for (x, y) in zip(
glyph_args,
list(accumulate([len(x.bitmap_data) for x in glyph_args])),
strict=True,
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
)
]

View File

@@ -74,6 +74,8 @@ def _final_validate(config):
if not use_interrupt:
return config
pin_num = config[CONF_PIN][CONF_NUMBER]
# Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt
# attachment — only internal/native GPIO pins do.
if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform:
@@ -85,8 +87,6 @@ def _final_validate(config):
config[CONF_USE_INTERRUPT] = False
return config
pin_num = config[CONF_PIN][CONF_NUMBER]
# GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt().
if CORE.is_esp8266 and pin_num == 16:
_LOGGER.warning(

View File

@@ -5,23 +5,7 @@ namespace esphome::gree {
static const char *const TAG = "gree.climate";
climate::ClimateTraits GreeClimate::traits() {
auto t = climate_ir::ClimateIR::traits();
// ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported.
if (!this->supports_heat_) {
auto modes = t.get_supported_modes();
modes.erase(climate::CLIMATE_MODE_HEAT_COOL);
t.set_supported_modes(modes);
}
return t;
}
void GreeClimate::set_model(Model model) {
if (model == GREE_YAN) {
// YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model.
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
}
if (model == GREE_YX1FF) {
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed
this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode

View File

@@ -94,7 +94,6 @@ class GreeClimate : public climate_ir::ClimateIR {
protected:
// Transmit via IR the state of this climate controller.
void transmit_state() override;
climate::ClimateTraits traits() override;
uint8_t operation_mode_();
uint8_t fan_speed_();

View File

@@ -63,88 +63,71 @@ void GrowattSolar::on_modbus_data(const std::vector<uint8_t> &data) {
switch (this->protocol_version_) {
case RTU: {
publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1);
publish_1_reg_sensor_state(this->inverter_status_, 0, 1);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER,
RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER,
RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER,
RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION,
RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT);
break;
}
case RTU2: {
publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1);
publish_1_reg_sensor_state(this->inverter_status_, 0, 1);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1,
ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT);
publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER,
RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER,
RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER,
RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1,
ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION,
RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT);
publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT);
break;
}
}

View File

@@ -16,55 +16,6 @@ enum GrowattProtocolVersion {
RTU2,
};
// Register addresses for the RTU protocol.
constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1
constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2
constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1
constexpr size_t RTU_PV1_CURRENT = 4; // length = 1
constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2
constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1
constexpr size_t RTU_PV2_CURRENT = 8; // length = 1
constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2
constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2
constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1
constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1
constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1
constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2
constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1
constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1
constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2
constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1
constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1
constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2
constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2
constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2
constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1
// Input register addresses for the RTU2 protocol as described
// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document.
constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1
constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2
constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1
constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1
constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2
constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1
constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1
constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2
constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2
constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1
constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1
constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1
constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2
constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1
constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1
constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2
constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1
constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1
constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2
constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2
constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
class GrowattSolar : public PollingComponent, public modbus::ModbusDevice {
public:
void loop() override;

View File

@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TIMEZONE
from esphome.const import CONF_ID
from .. import homeassistant_ns
@@ -21,5 +21,3 @@ async def to_code(config):
await time_.register_time(var, config)
await cg.register_component(var, config)
cg.add_define("USE_HOMEASSISTANT_TIME")
if CONF_TIMEZONE not in config:
cg.add_define("USE_HOMEASSISTANT_TIMEZONE")

View File

@@ -14,7 +14,7 @@ from esphome.core import CORE
from .const import KEY_HOST
# force import gpio to register pin schema
from .gpio import host_pin_to_code # noqa: F401
from .gpio import host_pin_to_code # noqa
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
AUTO_LOAD = ["network", "preferences"]

View File

@@ -1,5 +1,3 @@
from pathlib import Path
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32
@@ -65,7 +63,7 @@ CONF_JSON = "json"
def validate_url(value):
value = cv.url(value)
if value.startswith(("http://", "https://")):
if value.startswith("http://") or value.startswith("https://"):
return value
raise cv.Invalid("URL must start with 'http://' or 'https://'")
@@ -176,7 +174,7 @@ async def to_code(config):
if config.get(CONF_VERIFY_SSL):
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
with Path(ca_cert_path).open(encoding="utf-8") as f:
with open(ca_cert_path, encoding="utf-8") as f:
ca_cert_content = f.read()
cg.add(var.set_ca_certificate(ca_cert_content))
else:

View File

@@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All(
cv.Optional(CONF_MD5): cv.templatable(
cv.All(cv.string, cv.Length(min=32, max=32))
),
cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)),
cv.Optional(CONF_PASSWORD): cv.templatable(cv.string),
cv.Optional(CONF_USERNAME): cv.templatable(cv.string),
cv.Required(CONF_URL): cv.templatable(cv.url),
}

View File

@@ -1,6 +1,4 @@
import logging
import re
import sys
from esphome import pins
import esphome.codegen as cg
@@ -31,7 +29,6 @@ from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_DEVICE,
CONF_FREQUENCY,
CONF_I2C,
CONF_I2C_ID,
@@ -43,7 +40,6 @@ from esphome.const import (
CONF_TIMEOUT,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_NRF52,
PLATFORM_RP2040,
PlatformFramework,
@@ -60,7 +56,6 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus)
ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component)
IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component)
ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component)
HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component)
I2CDevice = i2c_ns.class_("I2CDevice")
ESP32_I2C_CAPABILITIES = {
@@ -88,12 +83,6 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled"
MULTI_CONF = True
def validate_device(value):
if not re.match(r"^/(?:[^/]+/)*[^/]+$", value):
raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)")
return value
def _bus_declare_type(value):
if CORE.is_esp32:
return cv.declare_id(IDFI2CBus)(value)
@@ -101,8 +90,6 @@ def _bus_declare_type(value):
return cv.declare_id(ArduinoI2CBus)(value)
if CORE.using_zephyr:
return cv.declare_id(ZephyrI2CBus)(value)
if CORE.is_host:
return cv.declare_id(HostI2CBus)(value)
raise NotImplementedError
@@ -134,48 +121,15 @@ def validate_config(config):
return config
def validate_host_config(config):
if CORE.is_host:
# Host I2C is currently only supported on Linux
if not sys.platform.lower().startswith("linux"):
raise cv.Invalid(
"I2C is only supported on Linux for the host platform. "
f"Current platform: {sys.platform}"
)
if CONF_SDA in config or CONF_SCL in config:
raise cv.Invalid(
"'sda' and 'scl' are not supported on host platform; use 'device' instead."
)
if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config:
raise cv.Invalid("Pull-up configuration is not supported on host platform.")
if CONF_DEVICE not in config:
raise cv.Invalid(
"'device' is required for host platform (e.g., /dev/i2c-0)."
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): _bus_declare_type,
cv.SplitDefault(
CONF_SDA,
esp32="SDA",
esp8266="SDA",
rp2040="SDA",
nrf52="SDA",
): pins.internal_gpio_pin_number,
cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number,
cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All(
cv.only_on_esp32, cv.boolean
),
cv.SplitDefault(
CONF_SCL,
esp32="SCL",
esp8266="SCL",
rp2040="SCL",
nrf52="SCL",
): pins.internal_gpio_pin_number,
cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number,
cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All(
cv.only_on_esp32, cv.boolean
),
@@ -185,7 +139,6 @@ CONFIG_SCHEMA = cv.All(
esp8266="50kHz",
rp2040="50kHz",
nrf52="100kHz",
host="50kHz",
): cv.All(
cv.frequency,
cv.float_range(min=0, min_included=False),
@@ -202,22 +155,10 @@ CONFIG_SCHEMA = cv.All(
),
cv.boolean,
),
cv.Optional(CONF_DEVICE): cv.All(
cv.only_on(PLATFORM_HOST), validate_device
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on(
[
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_NRF52,
PLATFORM_HOST,
]
),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]),
validate_config,
validate_host_config,
)
@@ -276,13 +217,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
cg.add_global(i2c_ns.using)
cg.add_define("USE_I2C")
if CORE.is_host:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_device(config[CONF_DEVICE]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
elif CORE.using_zephyr:
if CORE.using_zephyr:
zephyr_add_prj_conf("I2C", True)
i2c = "i2c0"
if zephyr_data()[KEY_BOARD] == "xiao_ble":
@@ -309,40 +244,25 @@ async def to_code(config):
var = cg.new_Pvariable(
config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))")
)
await cg.register_component(var, config)
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
else:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_component(var, config)
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CORE.using_arduino and not CORE.is_esp32:
cg.add_library("Wire", None)
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CORE.using_arduino and not CORE.is_esp32:
cg.add_library("Wire", None)
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
def i2c_device_schema(default_address):
@@ -445,6 +365,5 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR},
"i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE},
}
)

View File

@@ -1,297 +0,0 @@
#ifdef USE_HOST
#if defined(__linux__)
#include "i2c_bus_host.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <linux/i2c.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <cstring>
namespace esphome::i2c {
static const char *const TAG = "i2c.host";
HostI2CBus::~HostI2CBus() {
if (this->file_descriptor_ != -1) {
close(this->file_descriptor_);
this->file_descriptor_ = -1;
}
}
void HostI2CBus::setup() {
ESP_LOGCONFIG(TAG, "Setting up I2C bus...");
// Open I2C device file
this->file_descriptor_ = open(this->device_.c_str(), O_RDWR);
if (this->file_descriptor_ == -1) {
int err = errno;
if (err == ENOENT) {
this->update_error_("not found");
} else if (err == EACCES) {
this->update_error_("permission denied");
} else {
this->update_error_(std::string("failed to open: ") + strerror(err));
}
this->mark_failed();
return;
}
this->initialized_ = true;
ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str());
// Run bus scan if enabled
if (this->scan_) {
this->i2c_scan_();
}
}
void HostI2CBus::dump_config() {
ESP_LOGCONFIG(TAG, "I2C Bus:");
ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str());
// Bus frequency cannot be set from userspace via i2c-dev; report it as informational only
ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_);
if (!this->first_error_.empty()) {
ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str());
}
if (this->scan_) {
ESP_LOGI(TAG, " Scan Results:");
for (const auto &s : this->scan_results_) {
if (s.second) {
ESP_LOGI(TAG, " 0x%02X: Found", s.first);
}
}
}
}
ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count,
uint8_t *read_buffer, size_t read_count) {
if (!this->initialized_) {
ESP_LOGE(TAG, "I2C bus not initialized");
return ERROR_NOT_INITIALIZED;
}
ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count);
// Handle special case: probe (no write data, no read data)
// This is used for device detection during bus scanning
if (write_count == 0 && read_count == 0) {
struct i2c_msg msg;
msg.addr = address;
msg.flags = 0;
msg.len = 0;
msg.buf = nullptr;
struct i2c_rdwr_ioctl_data rdwr_data;
rdwr_data.msgs = &msg;
rdwr_data.nmsgs = 1;
int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data);
if (ret < 0) {
int err = errno;
// If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses)
if (err == EOPNOTSUPP || err == ENOSYS) {
ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address);
if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT
return this->map_errno_to_error_code_(errno);
}
// Use I2C_SMBUS ioctl with Quick command
union i2c_smbus_data data;
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_WRITE;
args.command = 0;
args.size = I2C_SMBUS_QUICK;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
return this->map_errno_to_error_code_(errno);
}
return ERROR_OK;
}
return this->map_errno_to_error_code_(err);
}
return ERROR_OK;
}
// i2c_msg.len is a 16-bit field; reject transfers that would silently truncate
if (write_count > UINT16_MAX || read_count > UINT16_MAX) {
ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count,
(unsigned) UINT16_MAX);
return ERROR_TOO_LARGE;
}
// Prepare messages for combined write-read transaction
struct i2c_msg msgs[2];
int num_msgs = 0;
// Add write message if write data present
if (write_count > 0) {
msgs[num_msgs].addr = address;
msgs[num_msgs].flags = 0; // Write
msgs[num_msgs].len = write_count;
msgs[num_msgs].buf = const_cast<uint8_t *>(write_buffer);
num_msgs++;
}
// Add read message if read data requested
if (read_count > 0) {
msgs[num_msgs].addr = address;
msgs[num_msgs].flags = I2C_M_RD; // Read
msgs[num_msgs].len = read_count;
msgs[num_msgs].buf = read_buffer;
num_msgs++;
}
// Execute I2C transaction
struct i2c_rdwr_ioctl_data rdwr_data;
rdwr_data.msgs = msgs;
rdwr_data.nmsgs = num_msgs;
int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data);
if (ret < 0) {
int err = errno;
if (err == EOPNOTSUPP || err == ENOSYS) {
ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT
if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT
ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT
return this->map_errno_to_error_code_(errno);
}
// Perform write if needed
if (write_count > 0) {
ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count);
if (written != (ssize_t) write_count) {
int write_err = errno;
// If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort
if (write_err == EOPNOTSUPP || write_err == ENOSYS) {
ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT
// Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes
// Standard SMBus mapping: first byte is command, remaining bytes are data
if (write_count < 1) {
ESP_LOGE(TAG, "Write size too small for I2C_SMBUS");
return ERROR_INVALID_ARGUMENT;
}
if (write_count > I2C_SMBUS_BLOCK_MAX + 1) {
ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1);
return ERROR_INVALID_ARGUMENT;
}
union i2c_smbus_data data;
// Standard SMBus: first byte = command, rest = data
uint8_t command = write_buffer[0];
size_t data_len = write_count - 1;
data.block[0] = data_len;
if (data_len > 0) {
memcpy(&data.block[1], write_buffer + 1, data_len);
}
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_WRITE;
args.command = command;
args.size = I2C_SMBUS_I2C_BLOCK_DATA;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno));
return this->map_errno_to_error_code_(errno);
}
} else {
ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err));
return this->map_errno_to_error_code_(write_err);
}
}
}
// Perform read if needed
if (read_count > 0) {
ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count);
if (bytes_read != (ssize_t) read_count) {
int read_err = errno;
// If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort
if (read_err == EOPNOTSUPP || read_err == ENOSYS) {
ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT
// Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes
if (read_count > I2C_SMBUS_BLOCK_MAX) {
ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX);
return ERROR_INVALID_ARGUMENT;
}
union i2c_smbus_data data;
data.block[0] = read_count;
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_READ;
args.command = 0; // Start register/command
args.size = I2C_SMBUS_I2C_BLOCK_DATA;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno));
return this->map_errno_to_error_code_(errno);
}
// I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0];
// a short read means we did not receive all requested bytes
if (data.block[0] < read_count) {
ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count);
return ERROR_NOT_ACKNOWLEDGED;
}
// Copy data from SMBus buffer to output buffer
memcpy(read_buffer, &data.block[1], read_count);
} else {
ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err));
return this->map_errno_to_error_code_(read_err);
}
}
}
ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT
return ERROR_OK;
}
ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err));
return this->map_errno_to_error_code_(err);
}
ESP_LOGVV(TAG, "I2C transaction successful");
return ERROR_OK;
}
ErrorCode HostI2CBus::map_errno_to_error_code_(int err) {
switch (err) {
case ENXIO:
return ERROR_NOT_ACKNOWLEDGED;
case ETIMEDOUT:
return ERROR_TIMEOUT;
case EINVAL:
return ERROR_INVALID_ARGUMENT;
case ENODEV:
case ENOTTY:
return ERROR_NOT_INITIALIZED;
case EOPNOTSUPP:
case ENOSYS:
// Operation not supported - some I2C adapters don't support zero-length transactions
ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)");
return ERROR_NOT_ACKNOWLEDGED;
default:
ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err));
return ERROR_UNKNOWN;
}
}
void HostI2CBus::update_error_(const std::string &error) {
if (this->first_error_.empty()) {
this->first_error_ = error;
}
ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str());
}
} // namespace esphome::i2c
#else
#error "HostI2CBus is only supported on Linux"
#endif // defined(__linux__)
#endif // USE_HOST

View File

@@ -1,41 +0,0 @@
#pragma once
#ifdef USE_HOST
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "i2c_bus.h"
namespace esphome::i2c {
class HostI2CBus : public I2CBus, public Component {
public:
~HostI2CBus() override;
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::BUS; }
ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer,
size_t read_count) override;
void set_device(const std::string &device) { this->device_ = device; }
void set_scan(bool scan) { this->scan_ = scan; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
const std::string &get_device() const { return this->device_; }
protected:
void update_error_(const std::string &error);
ErrorCode map_errno_to_error_code_(int err);
std::string device_;
uint32_t frequency_{50000};
int file_descriptor_{-1};
bool initialized_{false};
std::string first_error_;
};
} // namespace esphome::i2c
#endif // USE_HOST

View File

@@ -170,7 +170,7 @@ def i2s_audio_component_schema(
min=1
),
cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All(
_validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE)
_validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE)
),
cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of(
*I2S_MODE_OPTIONS, lower=True

View File

@@ -98,19 +98,11 @@ def _set_stream_limits(config):
min_sample_rate=config.get(CONF_SAMPLE_RATE),
max_sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
# The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest
# stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit.
min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8
if config[CONF_I2S_MODE] == CONF_PRIMARY:
# Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the
# configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the
# slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum.
elif config[CONF_I2S_MODE] == CONF_PRIMARY:
# Primary mode has modifiable stream settings
audio.set_stream_limits(
min_bits_per_sample=min_bits_per_sample,
max_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
min_bits_per_sample=8,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
min_sample_rate=16000,
@@ -119,13 +111,13 @@ def _set_stream_limits(config):
else:
# Secondary mode has unmodifiable max bits per sample and min/max sample rates
audio.set_stream_limits(
min_bits_per_sample=min_bits_per_sample,
max_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
min_bits_per_sample=8,
max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE),
min_channels=1,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
max_sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
)
return config
@@ -142,11 +134,12 @@ def _validate_esp32_variant(config):
if config[CONF_DAC_TYPE] == "internal":
if variant not in INTERNAL_DAC_VARIANTS:
raise cv.Invalid(f"{variant} does not have an internal DAC")
elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8:
# The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an
# 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it
# at config time rather than emitting corrupted output at runtime.
raise cv.Invalid("8-bit audio is not supported on the original ESP32")
elif (
variant == esp32.VARIANT_ESP32
and config.get(CONF_BITS_PER_SAMPLE) == 8
and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT)
):
raise cv.Invalid("8-bit mono mode is not supported on ESP32")
return config

View File

@@ -3,7 +3,6 @@
#ifdef USE_ESP32
#include <driver/i2s_std.h>
#include <hal/dma_types.h>
#include "esphome/components/audio/audio.h"
#include "esphome/components/audio/audio_transfer_buffer.h"
@@ -17,16 +16,8 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10;
static constexpr size_t DMA_BUFFERS_COUNT = 5;
// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in
// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the
// speaker task reads the size actually allocated back from the driver rather than relying on this value.
#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE
static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED;
#else
static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED;
#endif
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
// invariant between i2s_event_queue_ and write_records_queue_).
@@ -36,17 +27,6 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
// without masking real failures.
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF
// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust
// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back
// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a
// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths.
static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) {
const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1);
return std::min(frames_from_duration, max_frames);
}
void I2SAudioSpeaker::dump_config() {
I2SAudioSpeakerBase::dump_config();
const char *fmt_str;
@@ -77,21 +57,8 @@ void I2SAudioSpeaker::run_speaker_task() {
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
// ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it
// clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through
// the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually
// allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is
// in the READY state here because start_i2s_driver() initialized it before this task was created.
size_t dma_buffer_bytes;
i2s_chan_info_t chan_info;
if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) {
// total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count.
dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT;
} else {
// Should not happen for a READY channel; fall back to the requested size.
dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_));
}
const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes);
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
bool successful_setup = false;
@@ -341,24 +308,12 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return ESP_ERR_NOT_SUPPORTED;
}
#ifdef USE_ESP32_VARIANT_ESP32
// The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample
// occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples
// tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple
// of 16, so reject anything else rather than emit corrupted audio.
if (audio_stream_info.get_bits_per_sample() % 16 != 0) {
ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit",
(unsigned) audio_stream_info.get_bits_per_sample());
return ESP_ERR_NOT_SUPPORTED;
}
#endif // USE_ESP32_VARIANT_ESP32
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
return ESP_ERR_INVALID_STATE;
}
uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info);
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
i2s_role_t i2s_role = this->i2s_role_;
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;

View File

@@ -395,7 +395,7 @@ def download_image(value):
def is_svg_file(file):
if not file:
return False
with Path(file).open("rb") as f:
with open(file, "rb") as f:
return "<svg" in str(f.read(1024))
@@ -408,7 +408,7 @@ def validate_file_shorthand(value):
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
if value.startswith(("http://", "https://")):
if value.startswith("http://") or value.startswith("https://"):
return download_image(value)
value = cv.file_(value)

View File

@@ -53,11 +53,7 @@ static_assert(
"re-evaluate for this target");
static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) {
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
auto *hw = LEDC_LL_GET_HW(0);
#else
auto *hw = LEDC_LL_GET_HW();
#endif
return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0;
}
#endif
@@ -165,9 +161,7 @@ void LEDCOutput::write_state(float state) {
void LEDCOutput::setup() {
if (!ledc_peripheral_reset_done) {
ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot");
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
PERIPH_RCC_ATOMIC() {
ledc_ll_enable_reset_reg(true);
ledc_ll_enable_reset_reg(false);

View File

@@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.storage_json import StorageJSON
from . import gpio # noqa: F401
from . import gpio # noqa
from .const import (
COMPONENT_BK72XX,
CONF_GPIO_RECOVER,
@@ -513,13 +513,13 @@ async def component_to_code(config):
# apply LibreTiny options from framework: block
# setup LT logger to work nicely with ESPHome logger
lt_options = {
"LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL],
"LT_LOGGER_CALLER": 0,
"LT_LOGGER_TASK": 0,
"LT_LOGGER_COLOR": 1,
"LT_USE_TIME": 1,
}
lt_options = dict(
LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL],
LT_LOGGER_CALLER=0,
LT_LOGGER_TASK=0,
LT_LOGGER_COLOR=1,
LT_USE_TIME=1,
)
# enable/disable per-module debugging
for module in framework[CONF_DEBUG]:
if module == "NONE":

View File

@@ -1,7 +1,7 @@
# Copyright (c) Kuba Szczodrzyński 2023-06-01.
# pylint: skip-file
# ruff: noqa: C408, I001
# flake8: noqa
import json
import re
@@ -313,12 +313,8 @@ def write_const(
# build component constants
comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components)
# replace the 2nd regex group only
code = re.sub(
comp_regex,
lambda m: m.group(1) + comp_str + m.group(3),
code,
flags=re.DOTALL | re.MULTILINE,
)
repl = lambda m: m.group(1) + comp_str + m.group(3)
code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
# regex for finding the family list block
fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)"
@@ -341,12 +337,8 @@ def write_const(
]
var_str = "\n".join(fam_lines)
# replace the 2nd regex group only
code = re.sub(
fam_regex,
lambda m: m.group(1) + var_str + m.group(3),
code,
flags=re.DOTALL | re.MULTILINE,
)
repl = lambda m: m.group(1) + var_str + m.group(3)
code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
# format with black
code = format_str(code, mode=FileMode())

View File

@@ -11,19 +11,11 @@
#include "esphome/core/time_64.h"
// IRAM_ATTR places a function in executable RAM so it is callable from an
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
// LibreTiny families that need it share the same .sram.text input section
// name; how that section is routed into RAM differs per family:
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
// top of .ram_image2.data (which IS in ltchiptool's
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
// in .ram_image2.text but that output section is NOT in
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
// there is dropped from the flashed binary.
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
// Each family uses a section its stock linker already routes to RAM:
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
// exception: its stock linker has no matching glob, so patch_linker.py
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
//
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
@@ -34,7 +26,13 @@
// layer.
#if defined(USE_BK72XX)
#define IRAM_ATTR
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
#else
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// LN882H: patch_linker.py.script injects *(.sram.text*) into
// .flash_copysection (> RAM0 AT> FLASH).
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
#endif
#define PROGMEM

View File

@@ -6,18 +6,12 @@ import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h). The
# input section name is always .sram.text; only the output section it lands
# in differs per family.
# section routed into RAM-executable memory (see esphome/core/hal.h).
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
# .ram_image2.text in sections_ram, so code there is silently dropped from
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
# .ram_image2.data (which IS extracted) instead.
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
@@ -40,20 +34,6 @@ _KEEP_LINE = (
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
# Inject at the top of .ram_image2.data, before __data_start__ so our code
# does not fall inside the data range markers. .ram_image2.data is one of the
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
#
# The regex is intentionally strict (no attribute / ALIGN between the section
# name and the opening brace, brace on its own line). If a future AmbZ SDK
# linker template changes this format, _pre_link raises RuntimeError on the
# unpatched .ld file(s), and the RTL8710B CI compile job in
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
# rather than silently shipping a binary with IRAM_ATTR code dropped from
# one or both OTA slots.
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
def _detect(env):
@@ -91,11 +71,12 @@ def _inject_keep(host_section):
# Variants not listed here intentionally have no .ld patcher:
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
}
@@ -106,14 +87,13 @@ def _patchers_for(variant):
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = []
unpatched = []
patched = 0
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched.append(name)
patched += 1
continue
content = original
for fn in _patchers:
@@ -122,9 +102,7 @@ def _pre_link(target, source, env):
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched.append(name)
else:
unpatched.append(name)
patched += 1
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
@@ -132,20 +110,6 @@ def _pre_link(target, source, env):
build_dir
)
)
# Every .ld in the build must be patched. RTL8710B generates one .ld per
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
# boot after an OTA into that slot.
if unpatched:
raise RuntimeError(
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
"these. Update the regex to cover all linker scripts.".format(
len(unpatched), len(ld_files), build_dir,
", ".join(unpatched), _variant,
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,

View File

@@ -58,7 +58,7 @@ from .effects import (
RGB_EFFECTS,
validate_effects,
)
from .types import ( # noqa: F401
from .types import ( # noqa
AddressableLight,
AddressableLightState,
ColorMode,

View File

@@ -11,12 +11,9 @@ from esphome.components.esp32 import (
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
add_idf_sdkconfig_option,
get_esp32_variant,
require_usb_serial_jtag_secondary,
@@ -116,12 +113,9 @@ UART_SELECTION_ESP32 = {
VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG],
VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32S2: [UART0, UART1, USB_CDC],
VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
}
UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1]
@@ -276,12 +270,9 @@ CONFIG_SCHEMA = cv.All(
esp32_c6=USB_SERIAL_JTAG,
esp32_c61=USB_SERIAL_JTAG,
esp32_h2=USB_SERIAL_JTAG,
esp32_h4=USB_SERIAL_JTAG,
esp32_h21=USB_SERIAL_JTAG,
esp32_p4=USB_SERIAL_JTAG,
esp32_s2=USB_CDC,
esp32_s3=USB_SERIAL_JTAG,
esp32_s31=USB_SERIAL_JTAG,
rp2040=USB_CDC,
bk72xx=DEFAULT,
ln882x=DEFAULT,
@@ -461,11 +452,7 @@ async def _late_logger_init(config: ConfigType) -> None:
cg.add_define("USE_LOGGER_USB_SERIAL_JTAG")
# USB Serial JTAG code is compiled when platform supports it.
# Enable secondary USB serial JTAG console so the VFS functions are available.
if (
CORE.is_esp32
and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG
and has_serial_logging
):
if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG:
require_usb_serial_jtag_secondary()
require_vfs_termios()
except cv.Invalid:
@@ -527,7 +514,7 @@ def validate_printf(value):
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
"""
""" # noqa
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
if len(matches) != len(value[CONF_ARGS]):
raise cv.Invalid(

View File

@@ -6,7 +6,7 @@
#include <driver/uart.h>
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#ifdef USE_LOGGER_USB_SERIAL_JTAG
#include <driver/usb_serial_jtag.h>
#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0)
#include <esp_vfs_dev.h>
@@ -29,7 +29,7 @@ namespace esphome::logger {
static const char *const TAG = "logger";
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#ifdef USE_LOGGER_USB_SERIAL_JTAG
static void init_usb_serial_jtag_() {
setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin
@@ -108,9 +108,7 @@ void Logger::pre_setup() {
#endif
#ifdef USE_LOGGER_USB_SERIAL_JTAG
case UART_SELECTION_USB_SERIAL_JTAG:
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
init_usb_serial_jtag_();
#endif
break;
#endif
}

View File

@@ -1,4 +1,3 @@
import functools
import importlib
from pathlib import Path
import pkgutil
@@ -7,7 +6,6 @@ import re
from esphome.automation import Trigger, build_automation, validate_automation
import esphome.codegen as cg
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_COLOR_DEPTH,
CONF_DRAW_ROUNDING,
@@ -31,10 +29,12 @@ from esphome.components.image import (
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_IDLE,
CONF_PAGES,
@@ -79,7 +79,7 @@ from .schemas import (
WIDGET_TYPES,
any_widget_schema,
container_schema,
obj_dict,
obj_schema,
)
from .styles import styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
@@ -173,7 +173,7 @@ def generate_lv_conf_h():
if clashes:
LOGGER.warning(
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
sorted(clashes),
sorted(list(clashes)),
)
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
@@ -213,73 +213,61 @@ def multi_conf_validate(configs: list[dict]):
def final_validation(config_list):
if len(config_list) != 1:
multi_conf_validate(config_list)
global_config = full_config.get()
# Resolve byte_order from display metadata before multi-config validation
for config in config_list:
metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]]
if any(m.has_writer for m in metas):
raise cv.Invalid(
"Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL"
)
if any(m.rotation != 0 for m in metas):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
config[CONF_DRAW_ROUNDING] = max(
[m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]]
)
display_byte_orders = {
m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED
}
if len(display_byte_orders) > 1:
raise cv.Invalid(
"All displays configured for an LVGL instance must use the same byte_order"
)
if display_byte_orders:
display_order = next(iter(display_byte_orders))
if CONF_BYTE_ORDER in config:
if config[CONF_BYTE_ORDER] != display_order:
raise cv.Invalid(
"LVGL byte order must match the display byte order",
[CONF_BYTE_ORDER],
)
else:
config[CONF_BYTE_ORDER] = display_order
if CONF_BYTE_ORDER not in config:
config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
raise cv.Invalid("At least one page must not be skipped")
for display_id in config[df.CONF_DISPLAYS]:
path = global_config.get_path_for_id(display_id)[:-1]
display = global_config.get_config_for_path(path)
if CONF_LAMBDA in display or CONF_PAGES in display:
raise cv.Invalid(
"Using lambda: or pages: in display config is not compatible with LVGL"
)
# treating 0 as false is intended here.
if display.get(CONF_ROTATION):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
raise cv.Invalid(
"Using auto_clear_enabled: true in display config not compatible with LVGL"
)
if draw_rounding := display.get(CONF_DRAW_ROUNDING):
config[CONF_DRAW_ROUNDING] = max(
draw_rounding, config[CONF_DRAW_ROUNDING]
)
buffer_frac = config[CONF_BUFFER_SIZE]
if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config:
df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
if len(config_list) != 1:
multi_conf_validate(config_list)
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]:
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if (
df.CONF_ADJUSTABLE in widget_conf
and not widget_conf[df.CONF_ADJUSTABLE]
):
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
async def to_code(configs):
@@ -378,7 +366,8 @@ async def to_code(configs):
# options will have CONF_ROTATION true if rotation is changed in an automation.
if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True:
if all(
get_display_metadata(disp).has_hardware_rotation for disp in displays
get_display_metadata(str(disp)).has_hardware_rotation
for disp in displays
):
rotation_type = RotationType.ROTATION_HARDWARE
df.LOGGER.info("LVGL will use hardware rotation via display driver")
@@ -529,32 +518,16 @@ def add_hello_world(config):
return config
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
# schemas.py for why chained .extend() is avoided here.
def _theme_schema(value):
return cv.Schema(
{
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
**{
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
for name, w in WIDGET_TYPES.items()
},
}
)
def _theme_schema(value: dict) -> dict:
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
)(value)
FINAL_VALIDATE_SCHEMA = final_validation
@@ -593,7 +566,7 @@ LVGL_SCHEMA = cv.All(
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(

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