Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 5777b1b916 Initial plan 2026-02-20 06:05:27 +00:00
2362 changed files with 36228 additions and 107376 deletions
+2 -195
View File
@@ -124,28 +124,6 @@ This document provides essential context for AI models interacting with this pro
* **Indentation:** Use spaces (two per indentation level), not tabs
* **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;`
* **Line length:** Wrap lines at no more than 120 characters
* **Constructor parameters vs setters:** Component properties that are both **required** and **invariant**
(never change after construction) should be constructor parameters rather than set via setter methods.
This makes the dependency explicit and prevents use of the object in an incompletely-initialized state.
In code generation, when calling `cg.new_Pvariable()` or the relevant helper function to create the component, pass these as arguments.
```cpp
// Good - required invariant dependency as constructor parameter
class SourceTextSensor : public text_sensor::TextSensor, public Component {
public:
explicit SourceTextSensor(text::Text *source) : source_(source) {}
protected:
text::Text *source_;
};
```
```cpp
// Bad - required invariant dependency as setter
class SourceTextSensor : public text_sensor::TextSensor, public Component {
public:
void set_source(text::Text *source) { this->source_ = source; }
protected:
text::Text *source_{nullptr};
};
```
* **Component Structure:**
* **Standard Files:**
@@ -239,123 +217,6 @@ This document provides essential context for AI models interacting with this pro
var = await switch.new_switch(config)
```
* **Automations (Triggers, Actions, Conditions):**
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
* **Triggers -- Callback method (preferred):**
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
**Python:**
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)
```
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
```python
for conf_key, forwarder in (
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)
```
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
```cpp
class MyComponent : public Component {
public:
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
protected:
// Use CallbackManager when callbacks are always registered (e.g. core components)
CallbackManager<void(bool)> state_callback_;
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
// (nullptr vs empty std::vector) per instance when no callbacks are added
// LazyCallbackManager<void(bool)> state_callback_;
};
```
* **Triggers -- Trigger class method:**
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
**Python:**
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
```
**C++:**
```cpp
class TurnOnTrigger : public Trigger<> {
public:
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
parent->add_on_state_callback([this](bool state) {
if (state && !this->last_on_)
this->trigger();
this->last_on_ = state;
});
}
protected:
bool last_on_;
};
```
* **Actions:**
```cpp
template<typename... Ts> class MyAction : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->do_something(); }
protected:
MyComponent *parent_;
};
```
Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
* **Conditions:**
```cpp
template<typename... Ts> class MyCondition : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_active(); }
protected:
MyComponent *parent_;
};
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
* **Configuration Validation:**
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -391,39 +252,10 @@ This document provides essential context for AI models interacting with this pro
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
├── test_build_components/
└── common/ # Shared bus packages (uart, i2c, spi, etc.)
│ ├── uart/ # UART at default baud rate
│ ├── uart_115200/ # UART at 115200 baud
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
└── test.rp2040-ard.yaml
├── test_build_components/ # Base test configurations
└── components/[component]/ # Component-specific tests
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
```
```yaml
# common.yaml — component config only, NO bus definitions
my_component:
id: my_instance
sensor:
- platform: my_component
name: My Sensor
```
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
./script/test_component_grouping.py -e config --all
@@ -454,7 +286,6 @@ This document provides essential context for AI models interacting with this pro
* **Documentation Contributions:**
* 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.
* **Best Practices:**
* **Component Development:** Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests.
@@ -563,30 +394,6 @@ This document provides essential context for AI models interacting with this pro
Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`).
**Callback Managers:**
ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method.
| Type | Idle overhead (32-bit) | When to use |
|------|----------------------|-------------|
| `CallbackManager<void(Ts...)>` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
| `LazyCallbackManager<void(Ts...)>` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
`LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
**Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature:
```cpp
// Bad -- forces heap allocation for forwarder structs
void add_on_state_callback(std::function<void(bool)> &&callback) {
this->state_callback_.add(std::move(callback));
}
// Good -- accepts any callable without forcing std::function wrapping
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
```
* **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
**Bad Pattern (Module-Level Globals):**
+1 -1
View File
@@ -1 +1 @@
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
3258307fa645ba77307e502075c02c4d710e92c48250839db3526d36a9655444
+1 -1
View File
@@ -12,7 +12,7 @@
"--privileged",
"-e",
"GIT_EDITOR=code --wait"
// uncomment and edit the path in order to pass through local USB serial to the container
// uncomment and edit the path in order to pass though local USB serial to the conatiner
// , "--device=/dev/ttyACM0"
],
"appPort": 6052,
+3 -4
View File
@@ -6,9 +6,8 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Developer breaking change (an API change that could break external components)
- [ ] Code quality improvements to existing code or addition of tests
- [ ] Other
@@ -25,7 +24,7 @@
- [ ] ESP32
- [ ] ESP32 IDF
- [ ] ESP8266
- [ ] RP2040/RP2350
- [ ] RP2040
- [ ] BK72xx
- [ ] RTL87xx
- [ ] LN882x
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
# yamllint disable-line rule:line-length
@@ -4,7 +4,6 @@ module.exports = {
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
TOO_BIG_MARKER: '<!-- too-big-request -->',
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
ORG_FORK_MARKER: '<!-- maintainer-access-warning -->',
MANAGED_LABELS: [
'new-component',
@@ -15,7 +14,6 @@ module.exports = {
'chained-pr',
'core',
'small-pr',
'medium-pr',
'dashboard',
'github-actions',
'by-code-owner',
@@ -29,7 +27,6 @@ module.exports = {
'new-feature',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
'code-quality',
'deprecated-component'
],
+73 -52
View File
@@ -1,13 +1,5 @@
const fs = require('fs');
const { DOCS_PR_PATTERNS } = require('./constants');
const {
COMPONENT_REGEX,
detectComponents,
hasCoreChanges,
hasDashboardChanges,
hasGitHubActionsChanges,
} = require('../detect-tags');
const { loadCodeowners, getEffectiveOwners } = require('../codeowners');
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
@@ -28,13 +20,15 @@ async function detectMergeBranch(context) {
// Strategy: Component and platform labeling
async function detectComponentPlatforms(changedFiles, apiData) {
const labels = new Set();
const componentRegex = /^esphome\/components\/([^\/]+)\//;
const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`);
for (const comp of detectComponents(changedFiles)) {
labels.add(`component: ${comp}`);
}
for (const file of changedFiles) {
const componentMatch = file.match(componentRegex);
if (componentMatch) {
labels.add(`component: ${componentMatch[1]}`);
}
const platformMatch = file.match(targetPlatformRegex);
if (platformMatch) {
labels.add(`platform: ${platformMatch[1]}`);
@@ -96,14 +90,20 @@ async function detectNewPlatforms(prFiles, apiData) {
// Strategy: Core files detection
async function detectCoreChanges(changedFiles) {
const labels = new Set();
if (hasCoreChanges(changedFiles)) {
const coreFiles = changedFiles.filter(file =>
file.startsWith('esphome/core/') ||
(file.startsWith('esphome/') && file.split('/').length === 2)
);
if (coreFiles.length > 0) {
labels.add('core');
}
return labels;
}
// Strategy: PR size detection
async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
const labels = new Set();
if (totalChanges <= SMALL_PR_THRESHOLD) {
@@ -111,11 +111,6 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
return labels;
}
if (totalChanges <= MEDIUM_PR_THRESHOLD) {
labels.add('medium-pr');
return labels;
}
const testAdditions = prFiles
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0), 0);
@@ -136,33 +131,80 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
// Strategy: Dashboard changes
async function detectDashboardChanges(changedFiles) {
const labels = new Set();
if (hasDashboardChanges(changedFiles)) {
const dashboardFiles = changedFiles.filter(file =>
file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/')
);
if (dashboardFiles.length > 0) {
labels.add('dashboard');
}
return labels;
}
// Strategy: GitHub Actions changes
async function detectGitHubActionsChanges(changedFiles) {
const labels = new Set();
if (hasGitHubActionsChanges(changedFiles)) {
const githubActionsFiles = changedFiles.filter(file =>
file.startsWith('.github/workflows/')
);
if (githubActionsFiles.length > 0) {
labels.add('github-actions');
}
return labels;
}
// Strategy: Code owner detection
async function detectCodeOwner(github, context, changedFiles) {
const labels = new Set();
const { owner, repo } = context.repo;
try {
const codeownersPatterns = loadCodeowners();
const { data: codeownersFile } = await github.rest.repos.getContent({
owner,
repo,
path: 'CODEOWNERS',
});
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
const prAuthor = context.payload.pull_request.user.login;
// Check if PR author is a codeowner of any changed file
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
if (effective.users.has(prAuthor)) {
labels.add('by-code-owner');
const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const codeownersRegexes = codeownersLines.map(line => {
const parts = line.split(/\s+/);
const pattern = parts[0];
const owners = parts.slice(1);
let regex;
if (pattern.endsWith('*')) {
const dir = pattern.slice(0, -1);
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
} else if (pattern.includes('*')) {
// First escape all regex special chars except *, then replace * with .*
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
regex = new RegExp(`^${regexPattern}$`);
} else {
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
}
return { regex, owners };
});
for (const file of changedFiles) {
for (const { regex, owners } of codeownersRegexes) {
if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
labels.add('by-code-owner');
return labels;
}
}
}
} catch (error) {
console.log('Failed to read or parse CODEOWNERS file:', error.message);
@@ -196,7 +238,6 @@ async function detectPRTemplateCheckboxes(context) {
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
{ pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' }
];
@@ -217,7 +258,7 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
const { owner, repo } = context.repo;
// Compile regex once for better performance
const componentFileRegex = COMPONENT_REGEX;
const componentFileRegex = /^esphome\/components\/([^\/]+)\//;
// Get files that are modified or added in components directory
const componentFiles = changedFiles.filter(file => componentFileRegex.test(file));
@@ -235,20 +276,19 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
}
}
// Get base branch ref to check if deprecation already exists for the component
// This prevents flagging a PR that simply adds deprecation
const baseRef = context.payload.pull_request.base.ref;
// Get PR head to fetch files from the PR branch
const prNumber = context.payload.pull_request.number;
// Check each component's __init__.py for DEPRECATED_COMPONENT constant
for (const component of components) {
const initFile = `esphome/components/${component}/__init__.py`;
try {
// Fetch file content from base branch using GitHub API
// Fetch file content from PR head using GitHub API
const { data: fileData } = await github.rest.repos.getContent({
owner,
repo,
path: initFile,
ref: baseRef
ref: `refs/pull/${prNumber}/head`
});
// Decode base64 content
@@ -281,24 +321,6 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
return { labels, deprecatedInfo };
}
// Strategy: Detect when maintainers cannot modify the PR branch
function detectMaintainerAccess(context) {
const pr = context.payload.pull_request;
// Only relevant for cross-repo PRs (forks)
if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
return null;
}
if (pr.maintainer_can_modify) {
return null;
}
const isOrgFork = pr.head.repo.owner.type === 'Organization';
console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
return { isOrgFork, orgName: pr.head.repo.owner.login };
}
// Strategy: Requirements detection
async function detectRequirements(allLabels, prFiles, context) {
const labels = new Set();
@@ -347,6 +369,5 @@ module.exports = {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
};
+6 -13
View File
@@ -12,10 +12,9 @@ const {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
} = require('./detectors');
const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
const { handleReviews } = require('./reviews');
const { applyLabels, removeOldLabels } = require('./labels');
// Fetch API data
@@ -36,7 +35,6 @@ async function fetchApiData() {
module.exports = async ({ github, context }) => {
// Environment variables
const SMALL_PR_THRESHOLD = parseInt(process.env.SMALL_PR_THRESHOLD);
const MEDIUM_PR_THRESHOLD = parseInt(process.env.MEDIUM_PR_THRESHOLD);
const MAX_LABELS = parseInt(process.env.MAX_LABELS);
const TOO_BIG_THRESHOLD = parseInt(process.env.TOO_BIG_THRESHOLD);
const COMPONENT_LABEL_THRESHOLD = parseInt(process.env.COMPONENT_LABEL_THRESHOLD);
@@ -115,22 +113,20 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
deprecatedResult,
maintainerAccess
deprecatedResult
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(prFiles),
detectNewPlatforms(prFiles, apiData),
detectCoreChanges(changedFiles),
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD),
detectDashboardChanges(changedFiles),
detectGitHubActionsChanges(changedFiles),
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles),
detectMaintainerAccess(context)
detectDeprecatedComponents(github, context, changedFiles)
]);
// Extract deprecated component info
@@ -180,11 +176,8 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews and org fork comment
await Promise.all([
handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
handleMaintainerAccessComment(github, context, maintainerAccess)
]);
// Handle reviews
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
// Apply labels
await applyLabels(github, context, finalLabels);
+7 -85
View File
@@ -2,8 +2,7 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
DEPRECATED_COMPONENT_MARKER,
ORG_FORK_MARKER
DEPRECATED_COMPONENT_MARKER
} = require('./constants');
// Generate review messages
@@ -41,36 +40,16 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
if (tooManyLabels && tooManyChanges) {
message +=
`(${nonTestChanges} line changes excluding tests, across ` +
`${originalLabelCount} different components/areas)`;
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
} else if (tooManyLabels) {
message +=
`(it touches ${originalLabelCount} different components/areas)`;
message += `This PR affects ${originalLabelCount} different components/areas.`;
} else {
message += `(${nonTestChanges} line changes excluding tests)`;
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
}
message += `, which makes it harder for maintainers to review.\n\n`;
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
`#how-to-approach-large-submissions`;
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
messages.push(message);
}
@@ -157,63 +136,6 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
}
}
// Handle maintainer access warning comment
async function handleMaintainerAccessComment(github, context, maintainerAccess) {
if (!maintainerAccess) {
return;
}
const { owner, repo } = context.repo;
const pr_number = context.issue.number;
const prAuthor = context.payload.pull_request.user.login;
// Check if we already posted the warning (iterate pages to exit early)
let existingComment;
for await (const { data: comments } of github.paginate.iterator(
github.rest.issues.listComments,
{ owner, repo, issue_number: pr_number }
)) {
existingComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body && comment.body.includes(ORG_FORK_MARKER)
);
if (existingComment) {
break;
}
}
if (existingComment) {
console.log('Maintainer access warning comment already exists, skipping');
return;
}
let body;
if (maintainerAccess.isOrgFork) {
body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
`GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
`See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
} else {
body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`Please enable this option in the PR sidebar to allow maintainer collaboration.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr_number,
body
});
console.log('Created maintainer access warning comment');
}
module.exports = {
handleReviews,
handleMaintainerAccessComment
handleReviews
};
-227
View File
@@ -1,227 +0,0 @@
// Shared CODEOWNERS parsing and matching utilities.
//
// Used by:
// - codeowner-review-request.yml
// - codeowner-approved-label-update.yml
// - auto-label-pr/detectors.js (detectCodeOwner)
/**
* Convert a CODEOWNERS glob pattern to a RegExp.
*
* Handles **, *, and ? wildcards after escaping regex-special characters.
*/
function globToRegex(pattern) {
let regexStr = pattern
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1')
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace
.replace(/\*/g, '[^/]*') // single star
.replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar
.replace(/\?/g, '.');
return new RegExp('^' + regexStr + '$');
}
/**
* Parse raw CODEOWNERS file content into an array of
* { pattern, regex, owners } objects.
*
* Each `owners` entry is the raw string from the file (e.g. "@user" or
* "@esphome/core").
*/
function parseCodeowners(content) {
const lines = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const patterns = [];
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const pattern = parts[0];
const owners = parts.slice(1);
const regex = globToRegex(pattern);
patterns.push({ pattern, regex, owners });
}
return patterns;
}
/**
* Fetch and parse the CODEOWNERS file via the GitHub API.
*
* @param {object} github - octokit instance from actions/github-script
* @param {string} owner - repo owner
* @param {string} repo - repo name
* @param {string} [ref] - git ref (SHA / branch) to read from
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
*/
async function fetchCodeowners(github, owner, repo, ref) {
const params = { owner, repo, path: 'CODEOWNERS' };
if (ref) params.ref = ref;
const { data: file } = await github.rest.repos.getContent(params);
const content = Buffer.from(file.content, 'base64').toString('utf8');
return parseCodeowners(content);
}
/**
* Classify raw owner strings into individual users and teams.
*
* @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"]
* @returns {{ users: string[], teams: string[] }}
* users login names without "@"
* teams team slugs without the "org/" prefix
*/
function classifyOwners(rawOwners) {
const users = [];
const teams = [];
for (const o of rawOwners) {
const clean = o.startsWith('@') ? o.slice(1) : o;
if (clean.includes('/')) {
teams.push(clean.split('/')[1]);
} else {
users.push(clean);
}
}
return { users, teams };
}
/**
* For each file, find its effective codeowners using GitHub's
* "last match wins" semantics, then union across all files.
*
* @param {string[]} files - list of file paths
* @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners
* @returns {{ users: Set<string>, teams: Set<string>, matchedFileCount: number }}
*/
function getEffectiveOwners(files, codeownersPatterns) {
const users = new Set();
const teams = new Set();
let matchedFileCount = 0;
for (const file of files) {
// Last matching pattern wins for each file
let effectiveOwners = null;
for (const { regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
effectiveOwners = owners;
}
}
if (effectiveOwners) {
matchedFileCount++;
const classified = classifyOwners(effectiveOwners);
for (const u of classified.users) users.add(u);
for (const t of classified.teams) teams.add(t);
}
}
return { users, teams, matchedFileCount };
}
/**
* Read and parse the CODEOWNERS file from disk.
*
* Use this when the repo is already checked out (avoids an API call).
*
* @param {string} [repoRoot='.'] - path to the repo root
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
*/
function loadCodeowners(repoRoot = '.') {
const fs = require('fs');
const path = require('path');
const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8');
return parseCodeowners(content);
}
/** Possible label actions returned by determineLabelAction. */
const LabelAction = Object.freeze({
ADD: 'add',
REMOVE: 'remove',
NONE: 'none',
});
/**
* Determine what label action is needed for a PR based on codeowner approvals.
*
* Checks changed files against CODEOWNERS patterns, reviews, and current labels
* to decide if the label should be added, removed, or left unchanged.
*
* @param {object} github - octokit instance from actions/github-script
* @param {string} owner - repo owner
* @param {string} repo - repo name
* @param {number} pr_number - pull request number
* @param {Array} codeownersPatterns - from loadCodeowners / fetchCodeowners
* @param {string} labelName - label to manage
* @returns {Promise<LabelAction>}
*/
async function determineLabelAction(github, owner, repo, pr_number, codeownersPatterns, labelName) {
// Get the list of changed files in this PR
const prFiles = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: pr_number }
);
const changedFiles = prFiles.map(file => file.filename);
console.log(`Found ${changedFiles.length} changed files`);
if (changedFiles.length === 0) {
console.log('No changed files found');
return LabelAction.NONE;
}
// Get effective owners using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
const componentCodeowners = effective.users;
console.log(`Component-specific codeowners: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
// Get current labels
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: pr_number
});
const hasLabel = currentLabels.some(label => label.name === labelName);
if (componentCodeowners.size === 0) {
console.log('No component-specific codeowners found');
return hasLabel ? LabelAction.REMOVE : LabelAction.NONE;
}
// Get all reviews and find latest per user
const reviews = await github.paginate(
github.rest.pulls.listReviews,
{ owner, repo, pull_number: pr_number }
);
const latestReviewByUser = new Map();
for (const review of reviews) {
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
latestReviewByUser.set(review.user.login, review);
}
// Check if any component-specific codeowner has an active approval
let hasCodeownerApproval = false;
for (const [login, review] of latestReviewByUser) {
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
console.log(`Codeowner '${login}' has approved`);
hasCodeownerApproval = true;
break;
}
}
if (hasCodeownerApproval && !hasLabel) return LabelAction.ADD;
if (!hasCodeownerApproval && hasLabel) return LabelAction.REMOVE;
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
return LabelAction.NONE;
}
module.exports = {
globToRegex,
parseCodeowners,
fetchCodeowners,
loadCodeowners,
classifyOwners,
getEffectiveOwners,
LabelAction,
determineLabelAction
};
-66
View File
@@ -1,66 +0,0 @@
/**
* Shared tag detection from changed file paths.
* Used by pr-title-check and auto-label-pr workflows.
*/
const COMPONENT_REGEX = /^esphome\/components\/([^\/]+)\//;
/**
* Detect component names from changed files.
* @param {string[]} changedFiles - List of changed file paths
* @returns {Set<string>} Set of component names
*/
function detectComponents(changedFiles) {
const components = new Set();
for (const file of changedFiles) {
const match = file.match(COMPONENT_REGEX);
if (match) {
components.add(match[1]);
}
}
return components;
}
/**
* Detect if core files were changed.
* Core files are in esphome/core/ or top-level esphome/ directory.
* @param {string[]} changedFiles - List of changed file paths
* @returns {boolean}
*/
function hasCoreChanges(changedFiles) {
return changedFiles.some(file =>
file.startsWith('esphome/core/') ||
(file.startsWith('esphome/') && file.split('/').length === 2)
);
}
/**
* Detect if dashboard files were changed.
* @param {string[]} changedFiles - List of changed file paths
* @returns {boolean}
*/
function hasDashboardChanges(changedFiles) {
return changedFiles.some(file =>
file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/')
);
}
/**
* Detect if GitHub Actions files were changed.
* @param {string[]} changedFiles - List of changed file paths
* @returns {boolean}
*/
function hasGitHubActionsChanges(changedFiles) {
return changedFiles.some(file =>
file.startsWith('.github/workflows/')
);
}
module.exports = {
COMPONENT_REGEX,
detectComponents,
hasCoreChanges,
hasDashboardChanges,
hasGitHubActionsChanges,
};
+3 -4
View File
@@ -12,7 +12,6 @@ permissions:
env:
SMALL_PR_THRESHOLD: 30
MEDIUM_PR_THRESHOLD: 100
MAX_LABELS: 15
TOO_BIG_THRESHOLD: 1000
COMPONENT_LABEL_THRESHOLD: 10
@@ -20,20 +19,20 @@ env:
jobs:
label:
runs-on: ubuntu-latest
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
+3 -3
View File
@@ -47,7 +47,7 @@ jobs:
fi
- if: failure()
name: Review PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -62,7 +62,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: generated-proto-files
path: |
@@ -70,7 +70,7 @@ jobs:
esphome/components/api/api_pb2_service.*
- if: success()
name: Dismiss review
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({
+4 -4
View File
@@ -40,9 +40,9 @@ jobs:
echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY
echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY
- if: failure() && github.event.pull_request.head.repo.full_name == github.repository
- if: failure()
name: Request changes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -53,9 +53,9 @@ jobs:
body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.'
})
- if: success() && github.event.pull_request.head.repo.full_name == github.repository
- if: success()
name: Dismiss review
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
with:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Set TAG
run: |
+36 -149
View File
@@ -39,7 +39,7 @@ jobs:
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
run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
# yamllint disable-line rule:line-length
@@ -58,7 +58,7 @@ jobs:
python -m venv venv
. venv/bin/activate
python --version
pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
pip install -r requirements.txt -r requirements_test.txt pre-commit
pip install -e .
pylint:
@@ -106,35 +106,6 @@ jobs:
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2040-boards.py --check
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Check import time against budget and write waterfall HAR
run: |
. venv/bin/activate
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
pytest:
name: Run pytest
@@ -182,12 +153,12 @@ jobs:
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -199,12 +170,9 @@ jobs:
- common
outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -214,7 +182,6 @@ jobs:
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -227,7 +194,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -243,12 +210,9 @@ jobs:
# Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -258,10 +222,9 @@ jobs:
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -283,7 +246,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -298,20 +261,9 @@ jobs:
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
env:
INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
run: |
. venv/bin/activate
if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
echo "Running all integration tests"
pytest -vv --no-cov --tb=native -n auto tests/integration/
else
# Parse JSON array into bash array to avoid shell expansion issues
mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
echo "Running ${#test_files[@]} specific integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
fi
pytest -vv --no-cov --tb=native -n auto tests/integration/
cpp-unit-tests:
name: Run C++ unit tests
@@ -340,40 +292,6 @@ jobs:
script/cpp_unit_test.py $ARGS
fi
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: >-
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Build benchmarks
id: build
run: |
. venv/bin/activate
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
# --build-only prints BUILD_BINARY=<path> to stdout
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # v4.15.0
with:
run: ${{ steps.build.outputs.binary }}
mode: simulation
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -417,14 +335,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -496,14 +414,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -585,14 +503,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -753,7 +671,7 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
env:
SKIP: pylint,clang-tidy-hash,ci-custom
SKIP: pylint,clang-tidy-hash
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
@@ -768,7 +686,7 @@ jobs:
ram_usage: ${{ steps.extract.outputs.ram_usage }}
flash_usage: ${{ steps.extract.outputs.flash_usage }}
cache_hit: ${{ steps.cache-memory-analysis.outputs.cache-hit }}
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
skip: ${{ steps.check-script.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -787,39 +705,10 @@ jobs:
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
fi
# Check if test files exist on the target branch for the requested
# components and platform. When a PR adds new test files for a platform,
# the target branch won't have them yet, so skip instead of failing.
# This check must be done here (not in determine-jobs.py) because
# determine-jobs runs on the PR branch and cannot see what the target
# branch has.
- name: Check for test files on target branch
id: check-tests
if: steps.check-script.outputs.skip != 'true'
run: |
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
found=false
for component in $(echo "$components" | jq -r '.[]'); do
# Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml)
for f in tests/components/${component}/test*.${platform}.yaml; do
if [ -f "$f" ]; then
found=true
break 2
fi
done
done
if [ "$found" = false ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis"
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
# All remaining steps only run if script and tests exist
# All remaining steps only run if script exists
- name: Generate cache key
id: cache-key
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
if: steps.check-script.outputs.skip != 'true'
run: |
# Get the commit SHA of the target branch
target_sha=$(git rev-parse HEAD)
@@ -846,14 +735,14 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.check-script.outputs.skip != 'true'
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
- name: Cache status
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
if: steps.check-script.outputs.skip != 'true'
run: |
if [ "${{ steps.cache-memory-analysis.outputs.cache-hit }}" == "true" ]; then
echo "✓ Cache hit! Using cached memory analysis results."
@@ -863,21 +752,21 @@ jobs:
fi
- name: Restore Python
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
- name: Build, compile, and analyze memory
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
id: build
run: |
. venv/bin/activate
@@ -898,8 +787,7 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" \
--base-only 2>&1 | \
-t "$platform" 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -912,15 +800,15 @@ jobs:
--platform "$platform"
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
- name: Extract memory usage for outputs
id: extract
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
if: steps.check-script.outputs.skip != 'true'
run: |
if [ -f memory-analysis-target.json ]; then
ram=$(jq -r '.ram_bytes' memory-analysis-target.json)
@@ -934,7 +822,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -960,7 +848,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -985,8 +873,7 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" \
--base-only 2>&1 | \
-t "$platform" 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -999,7 +886,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: memory-analysis-pr
path: memory-analysis-pr.json
@@ -1029,13 +916,13 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Download target analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: memory-analysis-target
path: ./memory-analysis
continue-on-error: true
- name: Download PR analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: memory-analysis-pr
path: ./memory-analysis
@@ -1,72 +0,0 @@
name: Close PR From Fork Default Branch
on:
# pull_request_target is required so we have permission to comment and close PRs from forks.
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
issues: write
jobs:
close:
name: Close PR opened from fork's default branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
&& github.event.pull_request.head.ref == github.event.repository.default_branch
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const headRepo = context.payload.pull_request.head.repo.full_name;
const body = [
`Hi @${author}, thanks for opening a pull request! :tada:`,
``,
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
``,
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
``,
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
``,
`\`\`\`bash`,
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
`git fetch upstream`,
`git checkout ${defaultBranch}`,
`git reset --hard upstream/${defaultBranch}`,
`git push --force-with-lease origin ${defaultBranch}`,
``,
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
`# ...re-apply your changes, then:`,
`git push origin my-feature-branch`,
`\`\`\``,
``,
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
``,
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: 'closed',
});
@@ -1,81 +0,0 @@
# Adds/removes a 'code-owner-approved' label when a component-specific
# codeowner approves (or dismisses) a PR.
#
# Uses pull_request_target so that fork PRs do not require workflow approval.
# The label is reconciled on every PR update; for review events specifically,
# this means the label is applied on the next push after a codeowner review.
name: Codeowner Approved Label
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
branches-ignore:
- release
- beta
permissions:
issues: write
pull-requests: read
contents: read
jobs:
codeowner-approved:
name: Run
if: ${{ github.repository == 'esphome/esphome' }}
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/scripts/codeowners.js
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = parseInt(process.env.PR_NUMBER, 10);
const LABEL_NAME = 'code-owner-approved';
console.log(`Processing PR #${pr_number} for codeowner approval label`);
const codeownersPatterns = loadCodeowners();
const action = await determineLabelAction(
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
);
if (action === LabelAction.NONE) {
console.log('No label change needed');
return;
}
try {
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (action === LabelAction.REMOVE) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr_number, name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
}
} catch (error) {
if (error.status === 403) {
console.log(`Warning: insufficient permissions to update label (expected for fork PRs)`);
} else if (error.status === 404) {
console.log(`Label '${LABEL_NAME}' not present, nothing to remove`);
} else {
throw error;
}
}
+84 -39
View File
@@ -13,9 +13,6 @@ on:
# Needs to be pull_request_target to get write permissions
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
branches-ignore:
- release
- beta
permissions:
pull-requests: write
@@ -27,17 +24,10 @@ jobs:
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Request reviews from component codeowners
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = context.payload.pull_request.number;
@@ -48,15 +38,12 @@ jobs:
const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->';
try {
// Get the list of changed files in this PR (with pagination)
const files = await github.paginate(
github.rest.pulls.listFiles,
{
owner,
repo,
pull_number: pr_number
}
);
// Get the list of changed files in this PR
const { data: files } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: pr_number
});
const changedFiles = files.map(file => file.filename);
console.log(`Found ${changedFiles.length} changed files`);
@@ -66,10 +53,32 @@ jobs:
return;
}
// Parse CODEOWNERS from the checked-out base branch
const codeownersPatterns = loadCodeowners();
// Fetch CODEOWNERS file from root
const { data: codeownersFile } = await github.rest.repos.getContent({
owner,
repo,
path: 'CODEOWNERS',
ref: context.payload.pull_request.base.sha
});
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
// Parse CODEOWNERS file to extract all patterns and their owners
const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const codeownersPatterns = [];
// Convert CODEOWNERS pattern to regex (robust glob handling)
function globToRegex(pattern) {
// Escape regex special characters except for glob wildcards
let regexStr = pattern
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars
.replace(/\*\*/g, '.*') // globstar
.replace(/\*/g, '[^/]*') // single star
.replace(/\?/g, '.'); // question mark
return new RegExp('^' + regexStr + '$');
}
// Helper function to create comment body
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
@@ -84,11 +93,50 @@ jobs:
}
}
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
const matchedOwners = effective.users;
const matchedTeams = effective.teams;
const matchedFileCount = effective.matchedFileCount;
for (const line of codeownersLines) {
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const pattern = parts[0];
const owners = parts.slice(1);
// Use robust glob-to-regex conversion
const regex = globToRegex(pattern);
codeownersPatterns.push({ pattern, regex, owners });
}
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
// Match changed files against CODEOWNERS patterns
const matchedOwners = new Set();
const matchedTeams = new Set();
const fileMatches = new Map(); // Track which files matched which patterns
for (const file of changedFiles) {
for (const { pattern, regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`);
if (!fileMatches.has(file)) {
fileMatches.set(file, []);
}
fileMatches.get(file).push({ pattern, owners });
// Add owners to the appropriate set (remove @ prefix)
for (const owner of owners) {
const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
if (cleanOwner.includes('/')) {
// Team mention (org/team-name)
const teamName = cleanOwner.split('/')[1];
matchedTeams.add(teamName);
} else {
// Individual user
matchedOwners.add(cleanOwner);
}
}
}
}
}
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
console.log('No codeowners found for any changed files');
@@ -122,14 +170,11 @@ jobs:
}
// Check for completed reviews to avoid re-requesting users who have already reviewed
const reviews = await github.paginate(
github.rest.pulls.listReviews,
{
owner,
repo,
pull_number: pr_number
}
);
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: pr_number
});
const reviewedUsers = new Set();
reviews.forEach(review => {
@@ -202,7 +247,7 @@ jobs:
}
const totalReviewers = reviewersList.length + teamsList.length;
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`);
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
// Request reviews
try {
@@ -234,7 +279,7 @@ jobs:
// Only add a comment if there are new codeowners to mention (not previously pinged)
if (reviewersList.length > 0 || teamsList.length > 0) {
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true);
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
await github.rest.issues.createComment({
owner,
@@ -252,7 +297,7 @@ jobs:
// Only try to add a comment if there are new codeowners to mention
if (reviewersList.length > 0 || teamsList.length > 0) {
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
try {
await github.rest.issues.createComment({
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add external component comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify codeowners for component issues
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const owner = context.repo.owner;
+1 -1
View File
@@ -8,4 +8,4 @@ on:
jobs:
lock:
uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0
uses: esphome/workflows/.github/workflows/lock.yml@main
-96
View File
@@ -1,96 +0,0 @@
name: PR Title Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches-ignore:
- release
- beta
permissions:
contents: read
pull-requests: read
jobs:
check:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const {
detectComponents,
hasCoreChanges,
hasDashboardChanges,
hasGitHubActionsChanges,
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
const author = context.payload.pull_request.user.login;
// Skip bot PRs (e.g. dependabot) - they have their own title format
if (author === 'dependabot[bot]') {
return;
}
// Block titles starting with "word:" or "word(scope):" patterns
const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/;
if (commitStylePattern.test(title)) {
core.setFailed(
`PR title should not start with a "prefix:" style format.\n` +
`Please use the format: [component] Brief description\n`
);
return;
}
// Get changed files to detect tags
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const filenames = files.map(f => f.filename);
// Detect tags from changed files using shared logic
const tags = new Set();
for (const comp of detectComponents(filenames)) {
tags.add(comp);
}
if (hasCoreChanges(filenames)) tags.add('core');
if (hasDashboardChanges(filenames)) tags.add('dashboard');
if (hasGitHubActionsChanges(filenames)) tags.add('ci');
if (tags.size === 0) {
return;
}
// Check for angle brackets not wrapped in backticks.
// Astro docs MDX treats bare < as JSX component opening tags.
const stripped = title.replace(/`[^`]*`/g, '');
if (/[<>]/.test(stripped)) {
core.setFailed(
'PR title contains `<` or `>` not wrapped in backticks.\n' +
'Astro docs MDX interprets bare `<` as JSX components.\n' +
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
);
return;
}
// Check title starts with [tag] prefix
const bracketPattern = /^\[\w+\]/;
if (!bracketPattern.test(title)) {
const suggestion = [...tags].map(c => `[${c}]`).join('');
// Skip if the suggested prefix would be too long for a readable title
if (suggestion.length > 40) {
return;
}
core.setFailed(
`PR modifies: ${[...tags].join(', ')}\n` +
`Title must start with a [tag] prefix.\n` +
`Suggested: ${suggestion} <description>`
);
}
+15 -15
View File
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to docker hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -138,7 +138,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -171,24 +171,24 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: digests-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -221,7 +221,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -229,7 +229,7 @@ jobs:
repositories: home-assistant-addon
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -264,7 +264,7 @@ jobs:
repositories: esphome-schema
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -287,7 +287,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -295,7 +295,7 @@ jobs:
repositories: version-notifier
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
+14 -13
View File
@@ -2,29 +2,30 @@ name: Status check labels
on:
pull_request:
types: [opened, reopened, labeled, unlabeled, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
types: [labeled, unlabeled]
jobs:
check:
name: Check blocking labels
name: Check ${{ matrix.label }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
label:
- needs-docs
- merge-after-release
- chained-pr
steps:
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- name: Check for ${{ matrix.label }} label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const labelNames = labels.map(l => l.name);
const found = blockingLabels.filter(bl => labelNames.includes(bl));
if (found.length > 0) {
core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`);
const hasLabel = labels.find(label => label.name === '${{ matrix.label }}');
if (hasLabel) {
core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}');
}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.14"
python-version: 3.13
- name: Install Home Assistant
run: |
@@ -41,7 +41,7 @@ jobs:
python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>
-1
View File
@@ -146,6 +146,5 @@ sdkconfig.*
/components
/managed_components
/dependencies.lock
api-docs/
+2 -7
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.12
rev: v0.15.1
hooks:
# Run the linter.
- id: ruff
@@ -37,7 +37,7 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py311-plus]
@@ -58,7 +58,6 @@ repos:
entry: python3 script/run-in-env.py pylint
language: system
types: [python]
files: ^esphome/.+\.py$
- id: clang-tidy-hash
name: Update clang-tidy hash
entry: python script/clang_tidy_hash.py --update-if-changed
@@ -66,7 +65,3 @@ repos:
files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$
pass_filenames: false
additional_dependencies: []
- id: ci-custom
name: ci-custom
entry: python3 script/run-in-env.py script/ci-custom.py
language: system
+4 -28
View File
@@ -54,9 +54,6 @@ esphome/components/atm90e32/* @circuitsetup @descipher
esphome/components/audio/* @kahrendt
esphome/components/audio_adc/* @kbx81
esphome/components/audio_dac/* @kbx81
esphome/components/audio_file/* @kahrendt
esphome/components/audio_file/media_source/* @kahrendt
esphome/components/audio_http/* @kahrendt
esphome/components/axs15231/* @clydebarrow
esphome/components/b_parasite/* @rbaron
esphome/components/ballu/* @bazuchan
@@ -93,7 +90,6 @@ esphome/components/bmp3xx_i2c/* @latonita
esphome/components/bmp3xx_spi/* @latonita
esphome/components/bmp581_base/* @danielkent-net @kahrendt
esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bthome_mithermometer/* @nagyrobi
@@ -134,7 +130,6 @@ esphome/components/dashboard_import/* @esphome/core
esphome/components/datetime/* @jesserockz @rfdarter
esphome/components/debug/* @esphome/core
esphome/components/delonghi/* @grob6000
esphome/components/dew_point/* @CFlix
esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter
@@ -143,13 +138,12 @@ esphome/components/dlms_meter/* @SimonFischer04
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/dsmr/* @glmnet @PolarGoose @zuidwijk
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
esphome/components/emmeti/* @E440QF
esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea
esphome/components/ens160/* @latonita
esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
@@ -219,8 +213,6 @@ esphome/components/hbridge/light/* @DotNetDann
esphome/components/hbridge/switch/* @dwmw2
esphome/components/hc8/* @omartijn
esphome/components/hdc2010/* @optimusprimespace @ssieb
esphome/components/hdc2080/* @G-Pereira @jesserockz
esphome/components/hdc302x/* @joshuasing
esphome/components/he60r/* @clydebarrow
esphome/components/heatpumpir/* @rob-deutsch
esphome/components/hitachi_ac424/* @sourabhjaiswal
@@ -248,6 +240,7 @@ esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/media_player/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/iaqcore/* @yozik04
@@ -322,7 +315,6 @@ esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
esphome/components/media_source/* @kahrendt
esphome/components/micro_wake_word/* @jesserockz @kahrendt
esphome/components/micronova/* @edenhaus @jorre05
esphome/components/microphone/* @jesserockz @kahrendt
@@ -333,7 +325,6 @@ esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz
@@ -347,7 +338,6 @@ esphome/components/modbus_controller/select/* @martgras @stegm
esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @martgras
esphome/components/modbus_server/* @exciton
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt
@@ -405,7 +395,6 @@ esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje
esphome/components/qspi_dbi/* @clydebarrow
esphome/components/qwiic_pir/* @kahrendt
esphome/components/radio_frequency/* @kbx81
esphome/components/radon_eye_ble/* @jeffeb3
esphome/components/radon_eye_rd200/* @jeffeb3
esphome/components/rc522/* @glmnet
@@ -417,12 +406,11 @@ esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
esphome/components/rp2040/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet @ximex
esphome/components/rtttl/* @glmnet
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
esphome/components/runtime_stats/* @bdraco
esphome/components/rx8130/* @beormund
@@ -440,15 +428,8 @@ esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
esphome/components/sfa30/* @ghsensdev
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
@@ -469,12 +450,8 @@ esphome/components/sn74hc165/* @jesserockz
esphome/components/socket/* @esphome/core
esphome/components/sonoff_d1/* @anatoly-savchenkov
esphome/components/sound_level/* @kahrendt
esphome/components/spa06_base/* @danielkent-net
esphome/components/spa06_i2c/* @danielkent-net
esphome/components/spa06_spi/* @danielkent-net
esphome/components/speaker/* @jesserockz @kahrendt
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
esphome/components/speaker_source/* @kahrendt
esphome/components/spi/* @clydebarrow @esphome/core
esphome/components/spi_device/* @clydebarrow
esphome/components/spi_led_strip/* @clydebarrow
@@ -605,8 +582,7 @@ esphome/components/xl9535/* @mreditor97
esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zigbee/* @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
esphome/components/zwave_proxy/* @kbx81
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.5.0-dev
PROJECT_NUMBER = 2026.3.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
-1
View File
@@ -4,5 +4,4 @@ include requirements.txt
recursive-include esphome *.yaml
recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
+1 -1
View File
@@ -1,4 +1,4 @@
# ESPHome [![Discord Chat](https://img.shields.io/discord/429907082951524364.svg)](https://discord.gg/KhAMKrd) [![GitHub release](https://img.shields.io/github/release/esphome/esphome.svg)](https://GitHub.com/esphome/esphome/releases/) [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/esphome/esphome)
# ESPHome [![Discord Chat](https://img.shields.io/discord/429907082951524364.svg)](https://discord.gg/KhAMKrd) [![GitHub release](https://img.shields.io/github/release/esphome/esphome.svg)](https://GitHub.com/esphome/esphome/releases/)
<a href="https://esphome.io/">
<picture>
+45 -482
View File
@@ -1,7 +1,6 @@
# PYTHON_ARGCOMPLETE_OK
import argparse
from collections.abc import Callable
from contextlib import suppress
from datetime import datetime
import functools
import getpass
@@ -10,8 +9,6 @@ import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
from typing import Protocol
@@ -26,7 +23,6 @@ import esphome.codegen as cg
from esphome.config import iter_component_configs, read_config, strip_default_ids
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
CONF_API,
CONF_BAUD_RATE,
CONF_BROKER,
@@ -39,7 +35,6 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -48,9 +43,7 @@ from esphome.const import (
CONF_SUBSTITUTIONS,
CONF_TOPIC,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_NATIVE_IDF,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
@@ -62,40 +55,18 @@ from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.types import ConfigType
from esphome.util import (
PICOTOOL_PACKAGE,
detect_rp2040_bootsel,
get_picotool_path,
get_serial_ports,
is_picotool_usb_permission_error,
list_yaml_files,
run_external_command,
run_external_process,
safe_print,
)
from esphome.zeroconf import discover_mdns_devices
_LOGGER = logging.getLogger(__name__)
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
# Maximum buffer size for serial log reading to prevent unbounded memory growth
SERIAL_BUFFER_MAX_SIZE = 65536
_RP2040_BOOTSEL_INSTRUCTIONS = (
"To enter BOOTSEL mode:\n"
" 1. Unplug the device\n"
" 2. Hold the BOOT/BOOTSEL button\n"
" 3. Plug in the USB cable while holding the button\n"
" 4. Release the button - the device should appear as a USB drive (RPI-RP2)\n"
"Then run the upload command again."
)
_RP2040_UDEV_HINT = (
"You may need to add a udev rule for RP2040 devices. "
"See: https://github.com/raspberrypi/picotool"
"/blob/master/udev/60-picotool.rules"
)
# Special non-component keys that appear in configs
_NON_COMPONENT_KEYS = frozenset(
{
@@ -191,7 +162,6 @@ class PortType(StrEnum):
NETWORK = "NETWORK"
MQTT = "MQTT"
MQTTIP = "MQTTIP"
BOOTSEL = "BOOTSEL"
# Magic MQTT port types that require special handling
@@ -206,64 +176,6 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
return [address]
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
the downstream resolution path (``resolve_ip_address``) can skip opening a
second Zeroconf client.
"""
from esphome.address_cache import AddressCache
if CORE.address_cache is None:
CORE.address_cache = AddressCache()
for host, addresses in hosts_to_addresses.items():
if addresses:
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
CORE.address_cache.add_mdns_addresses(host, addresses)
def _discover_mac_suffix_devices() -> list[str] | None:
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
then fall back to whatever default OTA address they normally use.
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
back to the base name: with ``name_add_mac_suffix`` enabled, the base
name by definition doesn't exist on the network.
- A non-empty sorted list of ``.local`` hostnames on success.
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
already have without opening a second Zeroconf client.
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
)
return []
_populate_mdns_cache(discovered)
return list(discovered)
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
When ``name_add_mac_suffix`` is enabled, returns discovered
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
caller should not fall back to the base name). Otherwise falls back to
the cache-resolved ``CORE.address``.
"""
if (discovered := _discover_mac_suffix_devices()) is not None:
return discovered
return _resolve_with_cache(CORE.address, purpose)
def choose_upload_log_host(
default: list[str] | str | None,
check_default: str | None,
@@ -302,14 +214,14 @@ def choose_upload_log_host(
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_ota_hostnames_for_default(purpose))
resolved.extend(_resolve_with_cache(CORE.address, purpose))
elif purpose == Purpose.UPLOADING:
if has_ota() and has_mqtt_ip_lookup():
resolved.append("MQTTIP")
if has_ota() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_ota_hostnames_for_default(purpose))
resolved.extend(_resolve_with_cache(CORE.address, purpose))
else:
resolved.append(device)
if not resolved:
@@ -328,61 +240,22 @@ def choose_upload_log_host(
(f"{port.path} ({port.description})", port.path) for port in get_serial_ports()
]
# Add RP2040 BOOTSEL device option when uploading
bootsel_permission_error = False
if (
purpose == Purpose.UPLOADING
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and (picotool := _find_picotool()) is not None
):
bootsel = detect_rp2040_bootsel(picotool)
if bootsel.device_count > 0:
options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL"))
elif bootsel.permission_error:
bootsel_permission_error = True
def add_ota_options() -> None:
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
if (discovered := _discover_mac_suffix_devices()) is not None:
# Discovery was applicable. Use whatever we found — on empty,
# intentionally skip the base-name fallback since with
# name_add_mac_suffix on, the base name doesn't exist on the net.
for host in discovered:
options.append((f"Over The Air ({host})", host))
elif has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
add_ota_options()
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
elif purpose == Purpose.UPLOADING and has_ota():
add_ota_options()
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
purpose == Purpose.UPLOADING
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
):
if bootsel_permission_error:
_LOGGER.warning(
"An RP2040 device in BOOTSEL mode was detected but could "
"not be accessed due to USB permissions."
)
if sys.platform.startswith("linux"):
_LOGGER.warning(_RP2040_UDEV_HINT)
if not options:
raise EsphomeError(
f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}"
)
_LOGGER.info("Tip: %s", _RP2040_BOOTSEL_INSTRUCTIONS)
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
if check_default is not None and check_default in [opt[1] for opt in options]:
return [check_default]
@@ -474,17 +347,7 @@ def has_resolvable_address() -> bool:
return not CORE.address.endswith(".local")
def has_name_add_mac_suffix() -> bool:
"""Check if name_add_mac_suffix is enabled in the config."""
if CORE.config is None:
return False
esphome_config = CORE.config.get(CONF_ESPHOME, {})
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
) -> list[str]:
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
@@ -497,9 +360,6 @@ def _resolve_network_devices(
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
- Expand hostnames that are already in ``CORE.address_cache`` to their
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
Zeroconf client to resolve them
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
@@ -524,29 +384,13 @@ def _resolve_network_devices(
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
network_devices.extend(mqtt_ips)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
@@ -559,13 +403,10 @@ def get_port_type(port: str) -> PortType:
Returns:
PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool
PortType.MQTT for MQTT logging
PortType.MQTTIP for MQTT IP lookup
PortType.NETWORK for IP addresses, hostnames, or mDNS names
"""
if port == "BOOTSEL":
return PortType.BOOTSEL
if port.startswith("/") or port.startswith("COM"):
return PortType.SERIAL
if port == "MQTT":
@@ -590,14 +431,6 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 1
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
process_stacktrace = getattr(module, "process_stacktrace")
except AttributeError:
pass
backtrace_state = False
ser = serial.Serial()
ser.baudrate = baud_rate
@@ -639,14 +472,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
)
safe_print(parser.parse_line(line, time_str))
if process_stacktrace:
backtrace_state = process_stacktrace(
config, line, backtrace_state
)
else:
backtrace_state = platformio_api.process_stacktrace(
config, line, backtrace_state=backtrace_state
)
backtrace_state = platformio_api.process_stacktrace(
config, line, backtrace_state=backtrace_state
)
except serial.SerialException:
_LOGGER.error("Serial port closed!")
return 0
@@ -786,47 +614,6 @@ def _check_and_emit_build_info() -> None:
)
def _get_configured_xtal_freq() -> int | None:
"""Read the configured crystal frequency from the sdkconfig file."""
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
if not sdkconfig_path.is_file():
return None
with suppress(OSError, ValueError):
content = sdkconfig_path.read_text()
for line in content.splitlines():
if line.startswith("CONFIG_XTAL_FREQ="):
return int(line.split("=", 1)[1])
return None
def _make_crystal_freq_callback(
configured_freq: int,
) -> Callable[[str], str | None]:
"""Create a callback that checks esptool crystal frequency output."""
crystal_re = re.compile(r"Crystal frequency:\s+(\d+)\s*MHz")
def check_crystal_line(line: str) -> str | None:
if not (match := crystal_re.search(line)):
return None
detected = int(match.group(1))
if detected == configured_freq:
return None
return (
f"\n\033[33mWARNING: Crystal frequency mismatch! "
f"Device reports {detected}MHz but firmware is configured "
f"for {configured_freq}MHz.\n"
f"UART logging and other clock-dependent features will not "
f"work correctly.\n"
f"Set the correct crystal frequency with sdkconfig_options:\n"
f" esp32:\n"
f" framework:\n"
f" sdkconfig_options:\n"
f" CONFIG_XTAL_FREQ_{detected}: 'y'\033[0m\n\n"
)
return check_crystal_line
def upload_using_esptool(
config: ConfigType, port: str, file: str, speed: int
) -> str | int:
@@ -846,15 +633,8 @@ def upload_using_esptool(
platformio_api.FlashImage(
path=idedata.firmware_bin_path, offset=firmware_offset
),
*idedata.extra_flash_images,
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
_LOGGER.warning(
"Skipping missing flash image declared by platform: %s",
image.path,
)
continue
flash_images.append(image)
mcu = "esp8266"
if CORE.is_esp32:
@@ -862,14 +642,6 @@ def upload_using_esptool(
mcu = get_esp32_variant().lower()
line_callbacks: list[Callable[[str], str | None]] = []
if (
CORE.is_esp32
and file is None
and (configured_freq := _get_configured_xtal_freq()) is not None
):
line_callbacks.append(_make_crystal_freq_callback(configured_freq))
def run_esptool(baud_rate):
cmd = [
"esptool",
@@ -894,13 +666,9 @@ def upload_using_esptool(
if os.environ.get("ESPHOME_USE_SUBPROCESS") is None:
import esptool
return run_external_command(
esptool.main, # pylint: disable=no-member
*cmd,
line_callbacks=line_callbacks,
)
return run_external_command(esptool.main, *cmd) # pylint: disable=no-member
return run_external_process(*cmd, line_callbacks=line_callbacks)
return run_external_process(*cmd)
rc = run_esptool(first_baudrate)
if rc == 0 or first_baudrate == 115200:
@@ -913,140 +681,15 @@ def upload_using_esptool(
return run_esptool(115200)
def upload_using_platformio(config: ConfigType, port: str) -> int:
def upload_using_platformio(config: ConfigType, port: str):
from esphome import platformio_api
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it.
# Create it here so the upload doesn't fail.
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040:
idedata = platformio_api.get_idedata(config)
build_dir = Path(idedata.firmware_elf_path).parent
firmware_bin = build_dir / "firmware.bin"
signed_bin = build_dir / "firmware.bin.signed"
if firmware_bin.is_file() and not signed_bin.is_file():
shutil.copy2(firmware_bin, signed_bin)
upload_args = ["-t", "upload", "-t", "nobuild"]
if port is not None:
upload_args += ["--upload-port", port]
return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args)
def _find_picotool() -> Path | None:
"""Find the picotool binary from PlatformIO packages."""
from esphome import platformio_api
try:
idedata = platformio_api.get_idedata(CORE.config)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
return None
return get_picotool_path(idedata.cc_path)
def upload_using_picotool(config: ConfigType) -> int:
"""Upload firmware to RP2040 in BOOTSEL mode using picotool.
Uses picotool to load the ELF firmware directly via USB, avoiding
the mass storage copy approach that causes "disk not ejected properly"
warnings on macOS.
"""
from esphome import platformio_api
idedata = platformio_api.get_idedata(config)
firmware_elf = Path(idedata.firmware_elf_path)
if not firmware_elf.is_file():
_LOGGER.error(
"Firmware ELF file not found at %s. "
"Make sure the project has been compiled first.",
firmware_elf,
)
return 1
picotool = get_picotool_path(idedata.cc_path)
if picotool is None:
_LOGGER.error(
"picotool not found. Ensure the RP2040 PlatformIO platform "
"is installed (%s).",
PICOTOOL_PACKAGE,
)
return 1
_LOGGER.info("Uploading firmware to RP2040 via picotool...")
try:
# Don't capture stdout — let picotool write directly to the terminal
# so progress bars display in real-time with \r updates.
# Capture stderr only so we can detect permission errors.
result = subprocess.run(
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
stderr=subprocess.PIPE,
timeout=60,
check=False,
)
except subprocess.TimeoutExpired:
_LOGGER.error("picotool upload timed out after 60 seconds.")
return 1
except OSError as err:
_LOGGER.error("Failed to run picotool: %s", err)
return 1
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace").strip()
if stderr:
for line in stderr.splitlines():
safe_print(line)
if is_picotool_usb_permission_error(stderr):
msg = "Permission denied accessing USB device."
if sys.platform.startswith("linux"):
msg += f" {_RP2040_UDEV_HINT}"
_LOGGER.error(msg)
else:
_LOGGER.error("picotool upload failed (exit code %d).", result.returncode)
return 1
return 0
def _wait_for_serial_port(
port: str | None = None,
timeout: float = 30.0,
known_ports: set[str] | None = None,
) -> None:
"""Wait for a serial port to appear, e.g. after a device reboot.
USB-CDC devices disappear briefly after flashing while the device
reboots and re-enumerates on the USB bus.
If port is given, wait for that specific path. If known_ports is
given, wait for a new port that wasn't in the set. Otherwise wait
for any serial port to appear.
"""
def _port_found() -> bool:
if port is not None:
if os.name == "posix":
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:
return any(p.path not in known_ports for p in ports)
return bool(ports)
if _port_found():
return
if port is not None:
_LOGGER.info("Waiting for %s to come online...", port)
else:
_LOGGER.info("Waiting for device to reboot...")
start = time.monotonic()
while time.monotonic() - start < timeout:
time.sleep(0.05)
if _port_found():
time.sleep(0.05)
return
def check_permissions(port: str):
if os.name == "posix" and get_port_type(port) == PortType.SERIAL:
# Check if we can open selected serial port
@@ -1076,15 +719,7 @@ def upload_program(
except AttributeError:
pass
port_type = get_port_type(host)
if port_type == PortType.BOOTSEL:
exit_code = upload_using_picotool(config)
# Return None for device - BOOTSEL can't be used for logging,
# so command_run will show the interactive chooser for log source
return exit_code, None
if port_type == PortType.SERIAL:
if get_port_type(host) == PortType.SERIAL:
check_permissions(host)
exit_code = 1
@@ -1138,7 +773,6 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
port_type = get_port_type(port)
if port_type == PortType.SERIAL:
_wait_for_serial_port(port)
check_permissions(port)
return run_miniterm(config, port, args)
@@ -1149,11 +783,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
):
from esphome.components.api.client import run_logs
return run_logs(
config,
network_devices,
subscribe_states=not getattr(args, "no_states", False),
)
return run_logs(config, network_devices)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1186,7 +816,7 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
# add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
output = re.sub(
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[5m\2\\033[6m", output
)
if not CORE.quiet:
safe_print(output)
@@ -1281,9 +911,6 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
purpose=Purpose.UPLOADING,
)
# Snapshot current serial ports before upload so we can detect new ones
pre_upload_ports = {p.path for p in get_serial_ports()}
exit_code, successful_device = upload_program(config, args, devices)
if exit_code == 0:
_LOGGER.info("Successfully uploaded program.")
@@ -1294,19 +921,6 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
if args.no_logs:
return 0
# After BOOTSEL upload, wait for a new serial port to appear
# so it shows up in the log chooser
if (
successful_device is None
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
):
_wait_for_serial_port(known_ports=pre_upload_ports)
# If exactly one new serial port appeared, use it directly
serial_ports = get_serial_ports()
new_ports = [p for p in serial_ports if p.path not in pre_upload_ports]
if len(new_ports) == 1:
successful_device = new_ports[0].path
# For logs, prefer the device we successfully uploaded to
devices = choose_upload_log_host(
default=successful_device,
@@ -1330,6 +944,12 @@ def command_clean_all(args: ArgsProtocol) -> int | None:
return 0
def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import mqtt
return mqtt.get_fingerprint(config)
def command_version(args: ArgsProtocol) -> int | None:
safe_print(f"Version: {const.__version__}")
return 0
@@ -1345,38 +965,6 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
creator = ConfigBundleCreator(config)
if args.list_only:
files = creator.discover_files()
for bf in sorted(files, key=lambda f: f.path):
safe_print(f" {bf.path}")
_LOGGER.info("Found %d files", len(files))
return 0
result = creator.create_bundle()
if args.output:
output_path = Path(args.output)
else:
stem = CORE.config_path.stem
output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(result.data)
_LOGGER.info(
"Bundle created: %s (%d files, %.1f KB)",
output_path,
len(result.files),
len(result.data) / 1024,
)
return 0
def command_dashboard(args: ArgsProtocol) -> int | None:
from esphome.dashboard import dashboard
@@ -1448,8 +1036,9 @@ def command_update_all(args: ArgsProtocol) -> int | None:
files = list_yaml_files(args.configuration)
def build_command(f):
dashboard = ["--dashboard"] if CORE.dashboard else []
return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"]
if CORE.dashboard:
return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"]
return ["esphome", "run", f, "--no-logs", "--device", "OTA"]
return run_multiple_configs(files, build_command)
@@ -1598,7 +1187,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_path.write_text(new_raw, encoding="utf-8")
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
rc = run_external_process("esphome", "config", str(new_path))
if rc != 0:
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
@@ -1616,7 +1205,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
cli_args.insert(0, "--dashboard")
try:
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
rc = run_external_process("esphome", *cli_args)
except KeyboardInterrupt:
rc = 1
if rc != 0:
@@ -1648,11 +1237,11 @@ POST_CONFIG_ACTIONS = {
"run": command_run,
"clean": command_clean,
"clean-mqtt": command_clean_mqtt,
"mqtt-fingerprint": command_mqtt_fingerprint,
"idedata": command_idedata,
"rename": command_rename,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
}
SIMPLE_CONFIG_ACTIONS = [
@@ -1772,7 +1361,7 @@ def parse_args(argv):
parser_upload.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
)
parser_upload.add_argument(
"--upload_speed",
@@ -1795,7 +1384,7 @@ def parse_args(argv):
parser_logs.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
)
parser_logs.add_argument(
"--reset",
@@ -1804,11 +1393,6 @@ def parse_args(argv):
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
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",
@@ -1830,7 +1414,7 @@ def parse_args(argv):
parser_run.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
)
parser_run.add_argument(
"--upload_speed",
@@ -1867,6 +1451,13 @@ def parse_args(argv):
)
parser_wizard.add_argument("configuration", help="Your YAML configuration file.")
parser_fingerprint = subparsers.add_parser(
"mqtt-fingerprint", help="Get the SSL fingerprint from a MQTT broker."
)
parser_fingerprint.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
subparsers.add_parser("version", help="Print the ESPHome version and exit.")
parser_clean = subparsers.add_parser(
@@ -1954,24 +1545,6 @@ def parse_args(argv):
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_bundle = subparsers.add_parser(
"bundle",
help="Create a self-contained config bundle for remote compilation.",
)
parser_bundle.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_bundle.add_argument(
"-o",
"--output",
help="Output path for the bundle archive.",
)
parser_bundle.add_argument(
"--list-only",
help="List discovered files without creating the archive.",
action="store_true",
)
# Keep backward compatibility with the old command line format of
# esphome <config> <command>.
#
@@ -2037,7 +1610,7 @@ def run_esphome(argv):
# argv[0] is the program path, skip it since we prefix with "esphome"
def build_command(f):
return (
[*ESPHOME_COMMAND]
["esphome"]
+ [arg for arg in argv[1:] if arg not in args.configuration]
+ [str(f)]
)
@@ -2050,16 +1623,6 @@ def run_esphome(argv):
_LOGGER.warning("Skipping secrets file %s", conf_path)
return 0
# Bundle support: if the configuration is a .esphomebundle, extract it
# and rewrite conf_path to the extracted YAML config.
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
if is_bundle_path(conf_path):
_LOGGER.info("Extracting config bundle %s...", conf_path)
conf_path = prepare_bundle_for_compile(conf_path)
# Update the argument so downstream code sees the extracted path
args.configuration[0] = str(conf_path)
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
-11
View File
@@ -101,17 +101,6 @@ class AddressCache:
"""Check if any cache entries exist."""
return bool(self.mdns_cache or self.dns_cache)
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
"""Store resolved mDNS addresses for ``hostname`` in the cache.
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
this to avoid a second resolution round-trip during the upload path.
No-op when ``addresses`` is empty.
"""
if not addresses:
return
self.mdns_cache[normalize_hostname(hostname)] = addresses
@classmethod
def from_cli_args(
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
+3 -207
View File
@@ -1,6 +1,6 @@
"""Memory usage analyzer for ESPHome compiled binaries."""
from collections import Counter, defaultdict
from collections import defaultdict
from dataclasses import dataclass, field
import logging
from pathlib import Path
@@ -40,15 +40,6 @@ _READELF_SECTION_PATTERN = re.compile(
r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)"
)
# Regex for extracting call targets from objdump disassembly
# Matches direct call instructions across architectures:
# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12 <addr> <symbol>
# ARM: bl/blx <addr> <symbol>
# Captures the mangled symbol name inside angle brackets.
_CALL_TARGET_PATTERN = re.compile(
r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>"
)
# Component category prefixes
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
_COMPONENT_PREFIX_EXTERNAL = "[external]"
@@ -56,10 +47,6 @@ _COMPONENT_PREFIX_LIB = "[lib]"
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
# Placement new storage suffix (generated by codegen Pvariable)
_PSTORAGE_SUFFIX = "__pstorage"
# C++ namespace prefixes
_NAMESPACE_ESPHOME = "esphome::"
_NAMESPACE_STD = "std::"
@@ -205,27 +192,20 @@ class MemoryAnalyzer:
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
# Library symbol mapping: symbol_name -> library_name
self._lib_symbol_map: dict[str, str] = {}
# Source file symbol mapping: symbol_name -> component_name
# Used for extern "C" and other symbols without C++ namespace
self._source_symbol_map: dict[str, str] = {}
# Library dir to name mapping: "lib641" -> "espsoftwareserial",
# "espressif__mdns" -> "mdns"
self._lib_hash_to_name: dict[str, str] = {}
# Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns"
self._heuristic_to_lib: dict[str, str] = {}
# Function call counts: mangled_name -> call_count
self._function_call_counts: Counter[str] = Counter()
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._scan_libraries()
self._scan_source_symbols()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_sdk_libraries()
self._analyze_function_calls()
return dict(self.components)
def _parse_sections(self) -> None:
@@ -336,13 +316,6 @@ class MemoryAnalyzer:
# Demangle C++ names if needed
demangled = self._demangle_symbol(symbol_name)
# Check for placement new storage symbols (generated by codegen)
# Format: {component}__{id}__pstorage
if demangled.endswith(_PSTORAGE_SUFFIX) and (
component := self._match_pstorage_component(demangled)
):
return component
# Check for special component classes first (before namespace pattern)
# This handles cases like esphome::ESPHomeOTAComponent which should map to ota
if _NAMESPACE_ESPHOME in demangled:
@@ -378,11 +351,6 @@ class MemoryAnalyzer:
if lib_name := self._lib_symbol_map.get(symbol_name):
return f"{_COMPONENT_PREFIX_LIB}{lib_name}"
# Check source file mapping (catches extern "C" functions in ESPHome sources)
# Must be before heuristic patterns since source attribution is authoritative
if component := self._source_symbol_map.get(symbol_name):
return component
# Check against symbol patterns
for component, patterns in SYMBOL_PATTERNS.items():
if any(pattern in symbol_name for pattern in patterns):
@@ -410,33 +378,14 @@ class MemoryAnalyzer:
# Track uncategorized symbols for analysis
return "other"
def _match_pstorage_component(self, symbol_name: str) -> str | None:
"""Match a __pstorage symbol to its ESPHome component.
Symbol format: {component}__{id}__pstorage
The component namespace is embedded by codegen before the double underscore.
"""
prefix = symbol_name[: -len(_PSTORAGE_SUFFIX)]
# Extract component namespace before the first double underscore
dunder_pos = prefix.find("__")
if dunder_pos == -1:
return None
component_name = prefix[:dunder_pos]
if component_name in get_esphome_components():
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
if component_name in self.external_components:
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
return None
def _batch_demangle_symbols(self, symbols: list[str]) -> None:
"""Batch demangle C++ symbol names for efficiency."""
if not symbols:
return
_LOGGER.info("Demangling %d symbols", len(symbols))
demangled = batch_demangle(symbols, objdump_path=self.objdump_path)
self._demangle_cache.update(demangled)
_LOGGER.info("Successfully demangled %d symbols", len(demangled))
self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path)
_LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache))
def _demangle_symbol(self, symbol: str) -> str:
"""Get demangled C++ symbol name from cache."""
@@ -691,7 +640,6 @@ class MemoryAnalyzer:
return None
symbol_map: dict[str, str] = {}
source_symbol_map: dict[str, str] = {}
current_symbol: str | None = None
section_prefixes = (".text.", ".rodata.", ".data.", ".bss.", ".literal.")
@@ -727,18 +675,9 @@ class MemoryAnalyzer:
if dir_key in source_path:
symbol_map[current_symbol] = lib_name
break
else:
# Map ESPHome source files to components for extern "C"
# and other symbols without C++ namespace
component = self._source_file_to_component(source_path)
if component.startswith(
(_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
):
source_symbol_map[current_symbol] = component
current_symbol = None
self._source_symbol_map = source_symbol_map
return symbol_map or None
def _scan_libraries(self) -> None:
@@ -789,112 +728,6 @@ class MemoryAnalyzer:
len(libraries),
)
def _scan_source_symbols(self) -> None:
"""Scan ESPHome source object files to map extern "C" symbols to components.
When no linker map file is available, this uses ``nm`` to scan ``.o`` files
under ``src/esphome/`` and build a symbol-to-component mapping. This catches
``extern "C"`` functions and other symbols that lack C++ namespace prefixes.
Skips scanning if ``_source_symbol_map`` was already populated by
``_parse_map_file()``.
"""
if self._source_symbol_map or not self.nm_path:
return
obj_dir = self._find_object_files_dir()
if obj_dir is None:
return
# Find ESPHome source object files
esphome_src_dir = obj_dir / "src" / "esphome"
if not esphome_src_dir.is_dir():
return
obj_files = sorted(esphome_src_dir.rglob("*.o"))
if not obj_files:
return
# Run nm with --print-file-name to get file:symbol mapping
result = run_tool(
[self.nm_path, "--print-file-name", "-g", "--defined-only"]
+ [str(f) for f in obj_files],
)
if result is None or result.returncode != 0:
_LOGGER.debug("nm scan of source objects failed")
return
self._source_symbol_map = self._parse_nm_source_output(result.stdout, obj_dir)
if self._source_symbol_map:
_LOGGER.info(
"Built source symbol map from nm: %d symbols",
len(self._source_symbol_map),
)
def _parse_nm_source_output(self, output: str, base_dir: Path) -> dict[str, str]:
"""Parse nm output to map non-namespaced symbols to ESPHome components.
Extracts global defined symbols from ESPHome source object files that
don't use C++ namespacing (e.g. ``extern "C"`` functions).
Args:
output: Raw stdout from ``nm --print-file-name -g --defined-only``
or ``nm --print-file-name -S``.
base_dir: Build directory for computing relative paths.
Returns:
Dict mapping symbol names to component names.
"""
source_map: dict[str, str] = {}
for line in output.splitlines():
# Format: /path/to/file.o: addr type name
# or: /path/to/file.o: addr size type name (with -S)
colon_idx = line.rfind(".o:")
if colon_idx == -1:
continue
file_path = line[: colon_idx + 2]
fields = line[colon_idx + 3 :].split()
if len(fields) < 3:
continue
# With -S flag, format is: addr size type name
# Without -S flag: addr type name
# type is a single char; size is hex digits
# Detect by checking if fields[1] is a single uppercase letter (type)
if len(fields[1]) == 1 and fields[1].isalpha():
# addr type name
sym_type = fields[1]
symbol_name = fields[2]
elif len(fields) >= 4:
# addr size type name
sym_type = fields[2]
symbol_name = fields[3]
else:
continue
# Only global defined symbols (uppercase type)
if not sym_type.isupper() or sym_type == "U":
continue
# Skip symbols already in esphome:: namespace
if symbol_name.startswith("_ZN7esphome"):
continue
# Make path relative to base_dir for _source_file_to_component
try:
rel_path = str(Path(file_path).relative_to(base_dir))
except ValueError:
continue
component = self._source_file_to_component(rel_path)
if component.startswith(
(_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
):
source_map[symbol_name] = component
return source_map
def _find_object_files_dir(self) -> Path | None:
"""Find the directory containing object files for this build.
@@ -1178,43 +1011,6 @@ class MemoryAnalyzer:
total_size,
)
def _analyze_function_calls(self) -> None:
"""Count function call sites by parsing disassembly output.
Parses direct call instructions (call0/call8/bl/blx) from objdump -d
to count how many times each function is called. This helps identify
inlining candidates — frequently called small functions benefit most
from inlining.
"""
result = run_tool(
[self.objdump_path, "-d", str(self.elf_path)],
timeout=60,
)
if result is None or result.returncode != 0:
_LOGGER.debug("Failed to disassemble ELF for function call analysis")
return
self._function_call_counts = Counter(
match.group(1)
for line in result.stdout.splitlines()
if (match := _CALL_TARGET_PATTERN.search(line))
)
# Demangle any call targets not already in the cache
missing = [
name
for name in self._function_call_counts
if name not in self._demangle_cache
]
if missing:
self._batch_demangle_symbols(missing)
_LOGGER.debug(
"Function call analysis: %d unique targets, %d total calls",
len(self._function_call_counts),
sum(self._function_call_counts.values()),
)
def get_unattributed_ram(self) -> tuple[int, int, int]:
"""Get unattributed RAM sizes (SDK/framework overhead).
+13 -144
View File
@@ -15,7 +15,6 @@ from . import (
_COMPONENT_PREFIX_ESPHOME,
_COMPONENT_PREFIX_EXTERNAL,
_COMPONENT_PREFIX_LIB,
_PSTORAGE_SUFFIX,
RAM_SECTIONS,
MemoryAnalyzer,
)
@@ -24,17 +23,6 @@ if TYPE_CHECKING:
from . import ComponentMemory
def _format_pstorage_name(name: str) -> str:
"""Format a __pstorage symbol as 'storage for {id}'."""
if not name.endswith(_PSTORAGE_SUFFIX):
return name
prefix = name[: -len(_PSTORAGE_SUFFIX)]
# Strip component namespace prefix: {component}__{id} -> {id}
dunder_pos = prefix.find("__")
var_id = prefix[dunder_pos + 2 :] if dunder_pos != -1 else prefix
return f"storage for {var_id}"
class MemoryAnalyzerCLI(MemoryAnalyzer):
"""Memory analyzer with CLI-specific report generation."""
@@ -160,14 +148,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
If section is one of the RAM sections (.data or .bss), a label like
" [data]" or " [bss]" is appended. For non-RAM sections or when
section is None, no section label is added.
Placement new storage symbols are formatted as "storage for {id}".
"""
display_name = _format_pstorage_name(demangled)
section_label = ""
if section in RAM_SECTIONS:
section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss]
return f"{display_name} ({size:,} B){section_label}"
return f"{demangled} ({size:,} B){section_label}"
def _add_top_symbols(self, lines: list[str]) -> None:
"""Add a section showing the top largest symbols in the binary."""
@@ -190,13 +175,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
for i, (_, demangled, size, section, component) in enumerate(top_symbols):
# Format section label
section_label = f"[{section[1:]}]" if section else ""
# Format storage symbols readably
display_name = _format_pstorage_name(demangled)
# Truncate if too long
# Truncate demangled name if too long
demangled_display = (
f"{display_name[:truncate_limit]}..."
if len(display_name) > self.COL_TOP_SYMBOL_NAME
else display_name
f"{demangled[:truncate_limit]}..."
if len(demangled) > self.COL_TOP_SYMBOL_NAME
else demangled
)
lines.append(
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
@@ -248,110 +231,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f" {size:>6,} B {sym_name}")
lines.append("")
# Number of top called functions to show
TOP_CALLS_LIMIT: int = 50
# Number of inlining candidates to show
INLINE_CANDIDATES_LIMIT: int = 25
# Maximum function size in bytes to consider for inlining
INLINE_SIZE_THRESHOLD: int = 16
def _build_symbol_sizes(self) -> dict[str, int]:
"""Build a size lookup from all component symbols: mangled_name -> size."""
return {
symbol: size
for symbols in self._component_symbols.values()
for symbol, _, size, _ in symbols
}
def _format_call_row(
self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int]
) -> str:
"""Format a single row for call frequency tables."""
demangled = self._demangle_cache.get(mangled, mangled)
if len(demangled) > 80:
demangled = f"{demangled[:77]}..."
size = symbol_sizes.get(mangled)
size_str = f"{size:>5,} B" if size is not None else " ?"
return f"{index:>3} {count:>5} {size_str} {demangled}"
def _add_call_table_header(self, lines: list[str]) -> None:
"""Add the header row for call frequency tables."""
lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function")
lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}")
def _add_function_call_analysis(self, lines: list[str]) -> None:
"""Add function call frequency analysis section.
Shows the most frequently called functions by call site count.
"""
self._add_section_header(lines, "Top Called Functions")
symbol_sizes = self._build_symbol_sizes()
# Sort by call count descending
sorted_calls = sorted(
self._function_call_counts.items(), key=lambda x: x[1], reverse=True
)
self._add_call_table_header(lines)
for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]):
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
total_calls = sum(self._function_call_counts.values())
lines.append("")
lines.append(
f"Total: {len(self._function_call_counts)} unique targets, "
f"{total_calls:,} call sites"
)
lines.append("")
def _add_inline_candidates(self, lines: list[str]) -> None:
"""Add inlining candidates section.
Shows frequently called functions that are small enough to benefit
from inlining (< 16 bytes). These are the best candidates for
reducing call overhead.
"""
self._add_section_header(
lines,
f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)",
)
symbol_sizes = self._build_symbol_sizes()
# Filter to small functions with known size, sort by call count
candidates = sorted(
(
(mangled, count)
for mangled, count in self._function_call_counts.items()
if mangled in symbol_sizes
and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD
),
key=lambda x: x[1],
reverse=True,
)
if not candidates:
lines.append("No candidates found.")
lines.append("")
return
self._add_call_table_header(lines)
for i, (mangled, count) in enumerate(
candidates[: self.INLINE_CANDIDATES_LIMIT]
):
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
lines.append("")
lines.append(
f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} "
f"of {len(candidates)} functions under "
f"{self.INLINE_SIZE_THRESHOLD} B"
)
lines.append("")
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
@@ -590,16 +469,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f"Total size: {comp_mem.flash_total:,} B")
lines.append("")
# Show symbols above threshold, always include storage symbols
# Show all symbols above threshold for better visibility
large_symbols = [
(sym, dem, size, sec)
for sym, dem, size, sec in sorted_symbols
if size > self.SYMBOL_SIZE_THRESHOLD
or dem.endswith(_PSTORAGE_SUFFIX)
]
lines.append(
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):"
)
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
lines.append(
@@ -622,10 +500,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
# Sort by size descending
sorted_ram_syms = sorted(ram_syms, key=lambda x: x[2], reverse=True)
large_ram_syms = [
s
for s in sorted_ram_syms
if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
or s[1].endswith(_PSTORAGE_SUFFIX)
s for s in sorted_ram_syms if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
]
lines.append(f"{name} ({mem.ram_total:,} B total RAM):")
@@ -643,14 +518,13 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
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)
# Add ellipsis if name is truncated
display_name = (
f"{display_name[:70]}..."
if len(display_name) > 70
else display_name
demangled_display = (
f"{demangled[:70]}..." if len(demangled) > 70 else demangled
)
lines.append(
f" {size:>6,} B [{section_label}] {demangled_display}"
)
lines.append(f" {size:>6,} B [{section_label}] {display_name}")
if len(large_ram_syms) > 10:
lines.append(f" ... and {len(large_ram_syms) - 10} more")
lines.append("")
@@ -659,11 +533,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
if self._cswtch_symbols:
self._add_cswtch_analysis(lines)
# Function call frequency analysis
if self._function_call_counts:
self._add_function_call_analysis(lines)
self._add_inline_candidates(lines)
lines.append(
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
)
+1
View File
@@ -408,6 +408,7 @@ SYMBOL_PATTERNS = {
],
"arduino_core": [
"pinMode",
"resetPins",
"millis",
"micros",
"delay(", # More specific - Arduino delay function with parenthesis
-56
View File
@@ -1,56 +0,0 @@
"""Helpers for running an async coroutine from sync code via a daemon thread.
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
cycle finishes, which can add hundreds of milliseconds before the caller
receives the result. Running the loop in a daemon thread lets the caller
observe the result as soon as the coroutine completes while cleanup finishes
in the background.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import threading
from typing import Generic, TypeVar
_T = TypeVar("_T")
class AsyncThreadRunner(threading.Thread, Generic[_T]):
"""Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in
``exception`` so ``event`` is always set — this prevents callers waiting
on ``event`` from hanging forever when the coroutine crashes.
Typical usage::
runner = AsyncThreadRunner(lambda: my_coro(arg))
runner.start()
if not runner.event.wait(timeout=5.0):
... # timed out
if runner.exception is not None:
raise runner.exception
result = runner.result
"""
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
super().__init__(daemon=True)
self._coro_factory = coro_factory
self.result: _T | None = None
self.exception: BaseException | None = None
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
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
finally:
self.event.set()
def run(self) -> None:
asyncio.run(self._runner())
+17 -177
View File
@@ -1,6 +1,3 @@
from dataclasses import dataclass, field
import logging
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -60,42 +57,8 @@ def maybe_conf(conf, *validators):
return validate
_LOGGER = logging.getLogger(__name__)
def register_action(
name: str,
action_type: MockObjClass,
schema: cv.Schema,
*,
synchronous: bool | None = None,
):
"""Register an action type.
All callers must pass ``synchronous`` explicitly.
``synchronous=True`` — the action never defers ``play_next_()`` to a
later point (callback, timer, or ``loop()``). Trigger arguments are
only used during the initial call, so string args can use non-owning
StringRef for zero-copy access.
``synchronous=False`` — the action defers ``play_next_()`` via a
callback, timer, or ``Component::loop()``. Trigger arguments must
outlive the initial call, so string args use owning std::string to
prevent dangling references.
"""
if synchronous is None:
_LOGGER.warning(
"register_action('%s', ...) is missing the synchronous= parameter. "
"Defaulting to synchronous=False (safe but prevents StringRef "
"optimization). Check the C++ class: use synchronous=False if "
"play_next_() is deferred to a callback, timer, or loop(); "
"use synchronous=True if play_next_() always runs before the "
"initial play/play_complex call returns",
name,
)
synchronous = False
return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous)
def register_action(name: str, action_type: MockObjClass, schema: cv.Schema):
return ACTION_REGISTRY.register(name, action_type, schema)
def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema):
@@ -138,9 +101,6 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action)
SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action)
ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action)
Automation = cg.esphome_ns.class_("Automation")
TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder")
TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder")
TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder")
LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition)
StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition)
@@ -199,10 +159,11 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
return cv.Schema([schema])(value)
except cv.Invalid as err2:
if "extra keys not allowed" in str(err2) and len(err2.path) == 2:
raise err from None
# pylint: disable=raise-missing-from
raise err
if "Unable to find action" in str(err):
raise err2 from None
raise cv.MultipleInvalid([err, err2]) from None
raise err2
raise cv.MultipleInvalid([err, err2])
elif isinstance(value, dict):
if CONF_THEN in value:
return [schema(value)]
@@ -250,9 +211,7 @@ async def and_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
return cg.new_Pvariable(condition_id, template_arg, conditions)
@register_condition("or", OrCondition, validate_condition_list)
@@ -263,9 +222,7 @@ async def or_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
return cg.new_Pvariable(condition_id, template_arg, conditions)
@register_condition("all", AndCondition, validate_condition_list)
@@ -276,9 +233,7 @@ async def all_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
return cg.new_Pvariable(condition_id, template_arg, conditions)
@register_condition("any", OrCondition, validate_condition_list)
@@ -289,9 +244,7 @@ async def any_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
return cg.new_Pvariable(condition_id, template_arg, conditions)
@register_condition("not", NotCondition, validate_potentially_and_condition)
@@ -313,9 +266,7 @@ async def xor_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
return cg.new_Pvariable(condition_id, template_arg, conditions)
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
@@ -384,10 +335,7 @@ async def component_is_idle_condition_to_code(
@register_action(
"delay",
DelayAction,
cv.templatable(cv.positive_time_period_milliseconds),
synchronous=False,
"delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds)
)
async def delay_action_to_code(
config: ConfigType,
@@ -418,7 +366,6 @@ async def delay_action_to_code(
cv.has_at_least_one_key(CONF_THEN, CONF_ELSE),
cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL),
),
synchronous=True,
)
async def if_action_to_code(
config: ConfigType,
@@ -426,16 +373,13 @@ async def if_action_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
has_else = CONF_ELSE in config
# Prepend HasElse bool to template arguments: IfAction<HasElse, Ts...>
if_template_arg = cg.TemplateArguments(has_else, *template_arg)
cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION))
condition = await build_condition(config[cond_conf], template_arg, args)
var = cg.new_Pvariable(action_id, if_template_arg, condition)
var = cg.new_Pvariable(action_id, template_arg, condition)
if CONF_THEN in config:
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
if has_else:
if CONF_ELSE in config:
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
cg.add(var.add_else(actions))
return var
@@ -450,7 +394,6 @@ async def if_action_to_code(
cv.Required(CONF_THEN): validate_action_list,
}
),
synchronous=True,
)
async def while_action_to_code(
config: ConfigType,
@@ -474,7 +417,6 @@ async def while_action_to_code(
cv.Required(CONF_THEN): validate_action_list,
}
),
synchronous=True,
)
async def repeat_action_to_code(
config: ConfigType,
@@ -503,7 +445,7 @@ _validate_wait_until = cv.maybe_simple_value(
)
@register_action("wait_until", WaitUntilAction, _validate_wait_until, synchronous=False)
@register_action("wait_until", WaitUntilAction, _validate_wait_until)
async def wait_until_action_to_code(
config: ConfigType,
action_id: ID,
@@ -519,12 +461,7 @@ async def wait_until_action_to_code(
return var
# Lambda executes user C++ inline and returns — synchronous by execution model.
# User code could theoretically store the StringRef for deferred use, but StringRef
# is a view type and storing views beyond their scope is always unsafe regardless
# of this optimization. Marking non-synchronous would disable StringRef for nearly
# all user services since most use lambda.
@register_action("lambda", LambdaAction, cv.lambda_, synchronous=True)
@register_action("lambda", LambdaAction, cv.lambda_)
async def lambda_action_to_code(
config: ConfigType,
action_id: ID,
@@ -543,7 +480,6 @@ async def lambda_action_to_code(
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
}
),
synchronous=True,
)
async def component_update_action_to_code(
config: ConfigType,
@@ -563,7 +499,6 @@ async def component_update_action_to_code(
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
}
),
synchronous=True,
)
async def component_suspend_action_to_code(
config: ConfigType,
@@ -586,7 +521,6 @@ async def component_suspend_action_to_code(
),
}
),
synchronous=True,
)
async def component_resume_action_to_code(
config: ConfigType,
@@ -597,7 +531,7 @@ async def component_resume_action_to_code(
comp = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, comp)
if CONF_UPDATE_INTERVAL in config:
template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32)
template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int)
cg.add(var.set_update_interval(template_))
return var
@@ -644,27 +578,6 @@ async def build_condition_list(
return conditions
def has_non_synchronous_actions(actions: ConfigType) -> bool:
"""Check if a validated action list contains any non-synchronous actions.
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
trigger args for later execution, making non-owning types like StringRef
unsafe.
"""
if isinstance(actions, list):
return any(has_non_synchronous_actions(item) for item in actions)
if isinstance(actions, dict):
for key in actions:
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
return True
return any(
has_non_synchronous_actions(v)
for v in actions.values()
if isinstance(v, (list, dict))
)
return False
async def build_automation(
trigger: MockObj, args: TemplateArgsType, config: ConfigType
) -> MockObj:
@@ -674,76 +587,3 @@ async def build_automation(
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
return obj
async def build_callback_automation(
parent: MockObj,
callback_method: str,
args: TemplateArgsType,
config: ConfigType,
forwarder: MockObj | MockObjClass | None = None,
) -> None:
"""Build an Automation and register it as a callback on the parent.
Eliminates the need for a Trigger wrapper object by registering the
automation's trigger() directly as a callback on the parent component.
Uses template forwarder structs so the compiler deduplicates the operator()
body across all call sites with the same signature. The forwarder must be
pointer-sized (single Automation* field) to fit inline in Callback::ctx_
and avoid heap allocation.
:param parent: The component object (e.g., button, sensor).
:param callback_method: Name of the callback method (e.g., "add_on_press_callback").
:param args: Automation template args as list of (type, name) tuples.
:param config: The automation config dict.
:param forwarder: Optional forwarder type to use instead of the default
TriggerForwarder<Ts...>. Pass any struct type whose aggregate init takes
a single Automation pointer (e.g., TriggerOnTrueForwarder).
"""
arg_types = [arg[0] for arg in args]
templ = cg.TemplateArguments(*arg_types)
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ)
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
# Use template forwarder structs for deduplication. The compiler generates
# one operator() per forwarder type; different automation pointers are just
# data in the struct.
if forwarder is None:
forwarder = TriggerForwarder.template(templ)
# RawExpression for aggregate init — both forwarder and obj are codegen
# MockObjs (not user input), and there's no Expression type for positional
# aggregate initialization (StructInitializer uses named fields).
cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
@dataclass(frozen=True, slots=True)
class CallbackAutomation:
"""A single callback automation entry for build_callback_automations."""
conf_key: str
callback_method: str
args: TemplateArgsType = field(default_factory=list)
forwarder: MockObj | MockObjClass | None = None
async def build_callback_automations(
parent: MockObj,
config: ConfigType,
entries: tuple[CallbackAutomation, ...],
) -> None:
"""Build multiple callback automations from a tuple of entries.
:param parent: The component object (e.g., button, sensor).
:param config: The full component config dict.
:param entries: Tuple of CallbackAutomation entries to process.
"""
for entry in entries:
for conf in config.get(entry.conf_key, []):
await build_callback_automation(
parent,
entry.callback_method,
entry.args,
conf,
forwarder=entry.forwarder,
)
+11 -12
View File
@@ -53,13 +53,6 @@ def get_project_cmakelists() -> str:
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag for flag in CORE.build_flags if flag.startswith("-D")]
extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
for compile_def in compile_defs
)
return f"""\
# Auto-generated by ESPHome
cmake_minimum_required(VERSION 3.16)
@@ -68,9 +61,6 @@ set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{extra_compile_options}
project({CORE.name})
"""
@@ -80,17 +70,21 @@ def get_component_cmakelists(minimal: bool = False) -> str:
idf_requires = [] if minimal else (get_available_components() or [])
requires_str = " ".join(idf_requires)
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")]
compile_defs_str = "\n ".join(compile_defs) if compile_defs else ""
# Extract compile options (-W flags, excluding linker flags)
compile_opts = [
flag
for flag in CORE.build_flags
if flag.startswith("-W") and not flag.startswith("-Wl,")
]
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
compile_opts_str = "\n ".join(compile_opts) if compile_opts else ""
# Extract linker options (-Wl, flags)
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
link_opts_str = "\n ".join(link_opts) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
@@ -110,6 +104,11 @@ idf_component_register(
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
# ESPHome compile definitions
target_compile_definitions(${{COMPONENT_LIB}} PUBLIC
{compile_defs_str}
)
# ESPHome compile options
target_compile_options(${{COMPONENT_LIB}} PUBLIC
{compile_opts_str}
-765
View File
@@ -1,765 +0,0 @@
"""Config bundle creator and extractor for ESPHome.
A bundle is a self-contained .tar.gz archive containing a YAML config
and every local file it depends on. Bundles can be created from a config
and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import io
import json
import logging
from pathlib import Path
import re
import shutil
import tarfile
from typing import Any
from esphome import const, yaml_util
from esphome.const import (
CONF_ESPHOME,
CONF_EXTERNAL_COMPONENTS,
CONF_INCLUDES,
CONF_INCLUDES_C,
CONF_PATH,
CONF_SOURCE,
CONF_TYPE,
)
from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB
# Directories preserved across bundle extractions (build caches)
_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio")
_BUNDLE_STAGING_DIR = ".bundle_staging"
class ManifestKey(StrEnum):
"""Keys used in bundle manifest.json."""
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
FILES = "files"
HAS_SECRETS = "has_secrets"
# String prefixes that are never local file paths
_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<")
# File extensions recognized when resolving relative path strings.
# A relative string with one of these extensions is resolved against the
# config directory and included if the file exists.
_KNOWN_FILE_EXTENSIONS = frozenset(
{
# Fonts
".ttf",
".otf",
".woff",
".woff2",
".pcf",
".bdf",
# Images
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".svg",
".ico",
".webp",
# Certificates
".pem",
".crt",
".key",
".der",
".p12",
".pfx",
# C/C++ includes
".h",
".hpp",
".c",
".cpp",
".ino",
# Web assets
".css",
".js",
".html",
}
)
# Matches !secret references in YAML text. This is intentionally a simple
# regex scan rather than a YAML parse — it may match inside comments or
# multi-line strings, which is the conservative direction (include more
# secrets rather than fewer).
_SECRET_RE = re.compile(r"!secret\s+(\S+)")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <key>`` references."""
keys: set[str] = set()
for fpath in yaml_files:
try:
text = fpath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
for match in _SECRET_RE.finditer(text):
keys.add(match.group(1))
return keys
@dataclass
class BundleFile:
"""A file to include in the bundle."""
path: str # Relative path inside the archive
source: Path # Absolute path on disk
@dataclass
class BundleResult:
"""Result of creating a bundle."""
data: bytes
manifest: dict[str, Any]
files: list[BundleFile]
@dataclass
class BundleManifest:
"""Parsed and validated bundle manifest."""
manifest_version: int
esphome_version: str
config_filename: str
files: list[str]
has_secrets: bool
class ConfigBundleCreator:
"""Creates a self-contained bundle from an ESPHome config."""
def __init__(self, config: dict[str, Any]) -> None:
self._config = config
self._config_dir = Path(CORE.config_dir).resolve()
self._config_path = Path(CORE.config_path).resolve()
self._files: list[BundleFile] = []
self._seen_paths: set[Path] = set()
self._secrets_paths: set[Path] = set()
def discover_files(self) -> list[BundleFile]:
"""Discover all files needed for the bundle."""
self._files = []
self._seen_paths = set()
self._secrets_paths = set()
# The main config file
self._add_file(self._config_path)
# Phase 1: YAML includes (tracked during config loading)
self._discover_yaml_includes()
# Phase 2: Component-referenced files from validated config
self._discover_component_files()
return list(self._files)
def create_bundle(self) -> BundleResult:
"""Create the bundle archive."""
files = self.discover_files()
# Determine which secret keys are actually referenced by the
# bundled YAML files so we only ship those, not the entire
# secrets.yaml which may contain secrets for other devices.
yaml_sources = [
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
]
used_secret_keys = _find_used_secret_keys(yaml_sources)
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
has_secrets = bool(filtered_secrets)
if has_secrets:
_LOGGER.warning(
"Bundle contains secrets (e.g. Wi-Fi passwords). "
"Do not share it with untrusted parties."
)
manifest = self._build_manifest(files, has_secrets=has_secrets)
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
# Add manifest first
manifest_data = json.dumps(manifest, indent=2).encode("utf-8")
_add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data)
# Add filtered secrets files
for rel_path, data in sorted(filtered_secrets.items()):
_add_bytes_to_tar(tar, rel_path, data)
# Add files in sorted order for determinism, skipping secrets
# files which were already added above with filtered content
for bf in sorted(files, key=lambda f: f.path):
if bf.source in self._secrets_paths:
continue
self._add_to_tar(tar, bf)
return BundleResult(data=buf.getvalue(), manifest=manifest, files=files)
def _add_file(self, abs_path: Path) -> bool:
"""Add a file to the bundle. Returns False if already added."""
abs_path = abs_path.resolve()
if abs_path in self._seen_paths:
return False
if not abs_path.is_file():
_LOGGER.warning("Bundle: skipping missing file %s", abs_path)
return False
rel_path = self._relative_to_config_dir(abs_path)
if rel_path is None:
_LOGGER.warning(
"Bundle: skipping file outside config directory: %s", abs_path
)
return False
self._seen_paths.add(abs_path)
self._files.append(BundleFile(path=rel_path, source=abs_path))
return True
def _add_directory(self, abs_path: Path) -> None:
"""Recursively add all files in a directory."""
abs_path = abs_path.resolve()
if not abs_path.is_dir():
_LOGGER.warning("Bundle: skipping missing directory %s", abs_path)
return
for child in sorted(abs_path.rglob("*")):
if child.is_file() and "__pycache__" not in child.parts:
self._add_file(child)
def _relative_to_config_dir(self, abs_path: Path) -> str | None:
"""Get a path relative to the config directory. Returns None if outside.
Always uses forward slashes for consistency in tar archives.
"""
try:
return abs_path.relative_to(self._config_dir).as_posix()
except ValueError:
return None
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
Deliberately uses a fresh re-parse and force-loads every deferred
``IncludeFile`` to include *all* potentially-reachable includes,
even branches not selected by the local substitutions. Bundles are
meant to be compiled on another system where command-line
substitution overrides may choose a different branch — e.g.
``!include network/${eth_model}/config.yaml`` must ship every
candidate so the remote build can pick any one.
Entries with unresolved substitution variables in the filename
path are skipped with a warning (they cannot be resolved without
the substitution pass).
Secrets files are tracked separately so we can filter them to
only include the keys this config actually references.
"""
# Must be a fresh parse: IncludeFile.load() caches its result in
# _content, and we discover files by listening for loader calls. On
# an already-parsed tree the cache is populated, .load() returns
# without calling the loader, the listener never fires, and the
# referenced files would be silently dropped from the bundle.
with yaml_util.track_yaml_loads() as loaded_files:
try:
data = yaml_util.load_yaml(self._config_path)
except EsphomeError:
_LOGGER.debug(
"Bundle: re-loading YAML for include discovery failed, "
"proceeding with partial file list"
)
else:
_force_load_include_files(data)
for fpath in loaded_files:
if fpath == self._config_path.resolve():
continue # Already added as config
if fpath.name in const.SECRETS_FILES:
self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
"""Walk the validated config for file references.
Uses a generic recursive walk to find file paths instead of
hardcoding per-component knowledge about config dict formats.
After validation, components typically resolve paths to absolute
using CORE.relative_config_path() or cv.file_(). Relative paths
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
esphome_conf = config.get(CONF_ESPHOME, {})
for include_path in esphome_conf.get(CONF_INCLUDES, []):
resolved = _resolve_include_path(include_path)
if resolved is None:
continue
if resolved.is_dir():
self._add_directory(resolved)
else:
self._add_file(resolved)
for include_path in esphome_conf.get(CONF_INCLUDES_C, []):
resolved = _resolve_include_path(include_path)
if resolved is not None:
self._add_file(resolved)
# external_components with source: local - directories
for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []):
source = ext_conf.get(CONF_SOURCE, {})
if not isinstance(source, dict):
continue
if source.get(CONF_TYPE) != "local":
continue
path = source.get(CONF_PATH)
if not path:
continue
p = Path(path)
if not p.is_absolute():
p = CORE.relative_config_path(p)
self._add_directory(p)
def _walk_config_for_files(self, obj: Any) -> None:
"""Recursively walk the config dict looking for file path references."""
if isinstance(obj, dict):
for value in obj.values():
self._walk_config_for_files(value)
elif isinstance(obj, (list, tuple)):
for item in obj:
self._walk_config_for_files(item)
elif isinstance(obj, Path):
if obj.is_absolute() and obj.is_file():
self._add_file(obj)
elif isinstance(obj, str):
self._check_string_path(obj)
def _check_string_path(self, value: str) -> None:
"""Check if a string value is a local file reference."""
# Fast exits for strings that cannot be file paths
if len(value) < 2 or "\n" in value:
return
if value.startswith(_NON_PATH_PREFIXES):
return
# File paths must contain a path separator or a dot (for extension)
if "/" not in value and "\\" not in value and "." not in value:
return
p = Path(value)
# Absolute path - check if it points to an existing file
if p.is_absolute():
if p.is_file():
self._add_file(p)
return
# Relative path with a known file extension - likely a component
# validator that forgot to resolve to absolute via cv.file_() or
# CORE.relative_config_path(). Warn and try to resolve.
if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS:
_LOGGER.warning(
"Bundle: non-absolute path in validated config: %s "
"(component validator should return absolute paths)",
value,
)
resolved = CORE.relative_config_path(p)
if resolved.is_file():
self._add_file(resolved)
def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]:
"""Build filtered secrets files containing only the referenced keys.
Returns a dict mapping relative archive path to YAML bytes.
"""
if not used_keys or not self._secrets_paths:
return {}
result: dict[str, bytes] = {}
for secrets_path in self._secrets_paths:
rel_path = self._relative_to_config_dir(secrets_path)
if rel_path is None:
continue
try:
all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False)
except EsphomeError:
_LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path)
continue
if not isinstance(all_secrets, dict):
continue
filtered = {k: v for k, v in all_secrets.items() if k in used_keys}
if filtered:
data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8")
result[rel_path] = data
return result
def _build_manifest(
self, files: list[BundleFile], *, has_secrets: bool
) -> dict[str, Any]:
"""Build the manifest.json content."""
return {
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.ESPHOME_VERSION: const.__version__,
ManifestKey.CONFIG_FILENAME: self._config_path.name,
ManifestKey.FILES: [f.path for f in files],
ManifestKey.HAS_SECRETS: has_secrets,
}
@staticmethod
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
"""Add a BundleFile to the tar archive with deterministic metadata."""
with open(bf.source, "rb") as f:
_add_bytes_to_tar(tar, bf.path, f.read())
def extract_bundle(
bundle_path: Path,
target_dir: Path | None = None,
) -> Path:
"""Extract a bundle archive and return the path to the config YAML.
Sanity checks reject path traversal, symlinks, absolute paths, and
oversized archives to prevent accidental file overwrites or extraction
outside the target directory. These are **not** a security boundary —
bundles are assumed to come from the user's own machine or a trusted
build pipeline.
Args:
bundle_path: Path to the .tar.gz bundle file.
target_dir: Directory to extract into. If None, extracts next to
the bundle file in a directory named after it.
Returns:
Absolute path to the extracted config YAML file.
Raises:
EsphomeError: If the bundle is invalid or extraction fails.
"""
bundle_path = bundle_path.resolve()
if not bundle_path.is_file():
raise EsphomeError(f"Bundle file not found: {bundle_path}")
if target_dir is None:
target_dir = _default_target_dir(bundle_path)
target_dir = target_dir.resolve()
target_dir.mkdir(parents=True, exist_ok=True)
# Read and validate the archive
try:
with tarfile.open(bundle_path, "r:gz") as tar:
manifest = _read_manifest_from_tar(tar)
_validate_tar_members(tar, target_dir)
tar.extractall(path=target_dir, filter="data")
except tarfile.TarError as err:
raise EsphomeError(f"Failed to extract bundle: {err}") from err
config_filename = manifest[ManifestKey.CONFIG_FILENAME]
config_path = target_dir / config_filename
if not config_path.is_file():
raise EsphomeError(
f"Bundle manifest references config '{config_filename}' "
f"but it was not found in the archive"
)
return config_path
def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
"""Read and validate the manifest from a bundle without full extraction.
Args:
bundle_path: Path to the .tar.gz bundle file.
Returns:
Parsed BundleManifest.
Raises:
EsphomeError: If the manifest is missing, invalid, or version unsupported.
"""
try:
with tarfile.open(bundle_path, "r:gz") as tar:
manifest = _read_manifest_from_tar(tar)
except tarfile.TarError as err:
raise EsphomeError(f"Failed to read bundle: {err}") from err
return BundleManifest(
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
files=manifest.get(ManifestKey.FILES, []),
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
)
def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]:
"""Read and validate manifest.json from an open tar archive."""
try:
member = tar.getmember(MANIFEST_FILENAME)
except KeyError:
raise EsphomeError("Invalid bundle: missing manifest.json") from None
f = tar.extractfile(member)
if f is None:
raise EsphomeError("Invalid bundle: manifest.json is not a regular file")
if member.size > MAX_MANIFEST_SIZE:
raise EsphomeError(
f"Invalid bundle: manifest.json too large "
f"({member.size} bytes, max {MAX_MANIFEST_SIZE})"
)
try:
manifest = json.loads(f.read())
except (json.JSONDecodeError, UnicodeDecodeError) as err:
raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err
# Version check
version = manifest.get(ManifestKey.MANIFEST_VERSION)
if version is None:
raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'")
if not isinstance(version, int) or version < 1:
raise EsphomeError(
f"Invalid bundle: manifest_version must be a positive integer, got {version!r}"
)
if version > CURRENT_MANIFEST_VERSION:
raise EsphomeError(
f"Bundle manifest version {version} is newer than this ESPHome "
f"version supports (max {CURRENT_MANIFEST_VERSION}). "
f"Please upgrade ESPHome to compile this bundle."
)
# Required fields
if ManifestKey.CONFIG_FILENAME not in manifest:
raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'")
return manifest
def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
"""Sanity-check tar members to prevent mistakes and accidental overwrites.
This is not a security boundary — bundles are created locally or come
from a trusted build pipeline. The checks catch malformed archives
and common mistakes (stray absolute paths, ``..`` components) that
could silently overwrite unrelated files.
"""
total_size = 0
for member in tar.getmembers():
# Reject absolute paths (Unix and Windows)
if member.name.startswith(("/", "\\")):
raise EsphomeError(
f"Invalid bundle: absolute path in archive: {member.name}"
)
# Reject path traversal (split on both / and \ for cross-platform)
parts = re.split(r"[/\\]", member.name)
if ".." in parts:
raise EsphomeError(
f"Invalid bundle: path traversal in archive: {member.name}"
)
# Reject symlinks
if member.issym() or member.islnk():
raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}")
# Ensure extraction stays within target_dir
target_path = (target_dir / member.name).resolve()
if not target_path.is_relative_to(target_dir):
raise EsphomeError(
f"Invalid bundle: file would extract outside target: {member.name}"
)
# Track total decompressed size
total_size += member.size
if total_size > MAX_DECOMPRESSED_SIZE:
raise EsphomeError(
f"Invalid bundle: decompressed size exceeds "
f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit"
)
def is_bundle_path(path: Path) -> bool:
"""Check if a path looks like a bundle file."""
return path.name.lower().endswith(BUNDLE_EXTENSION)
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
"""Add in-memory bytes to a tar archive with deterministic metadata."""
info = tarfile.TarInfo(name=name)
info.size = len(data)
info.mtime = 0
info.uid = 0
info.gid = 0
info.mode = 0o644
tar.addfile(info, io.BytesIO(data))
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
resolved during the substitution pass. During bundle discovery we need
the referenced files to actually load so the ``track_yaml_loads``
listener fires for them.
``IncludeFile`` instances with unresolved substitution variables in the
filename cannot be loaded — we skip and warn about those.
"""
if _seen is None:
_seen = set()
if isinstance(obj, yaml_util.IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
_LOGGER.warning(
"Bundle: cannot resolve !include %s (referenced from %s) "
"with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Bundle: failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
_force_load_include_files(loaded, _seen)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
_force_load_include_files(value, _seen)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
_force_load_include_files(item, _seen)
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
return None # System include, not a local file
p = Path(include_path)
if not p.is_absolute():
p = CORE.relative_config_path(p)
return p
def _default_target_dir(bundle_path: Path) -> Path:
"""Compute the default extraction directory for a bundle."""
name = bundle_path.name
if name.lower().endswith(BUNDLE_EXTENSION):
name = name[: -len(BUNDLE_EXTENSION)]
return bundle_path.parent / name
def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None:
"""Move preserved build cache directories back into target_dir.
If the bundle contained entries under a preserved directory name,
the extracted copy is removed so the original cache always wins.
"""
for dirname, src in preserved.items():
dst = target_dir / dirname
if dst.exists():
shutil.rmtree(dst)
shutil.move(str(src), str(dst))
def prepare_bundle_for_compile(
bundle_path: Path,
target_dir: Path | None = None,
) -> Path:
"""Extract a bundle for compilation, preserving build caches.
Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/
directories in the target if they already exist (for incremental builds).
Args:
bundle_path: Path to the .tar.gz bundle file.
target_dir: Directory to extract into. Must be specified for
build server use.
Returns:
Absolute path to the extracted config YAML file.
"""
bundle_path = bundle_path.resolve()
if not bundle_path.is_file():
raise EsphomeError(f"Bundle file not found: {bundle_path}")
if target_dir is None:
target_dir = _default_target_dir(bundle_path)
target_dir = target_dir.resolve()
target_dir.mkdir(parents=True, exist_ok=True)
preserved: dict[str, Path] = {}
# Temporarily move preserved dirs out of the way
staging = target_dir / _BUNDLE_STAGING_DIR
for dirname in _PRESERVE_DIRS:
src = target_dir / dirname
if src.is_dir():
dst = staging / dirname
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
preserved[dirname] = dst
try:
# Clean non-preserved content and extract fresh
for item in target_dir.iterdir():
if item.name == _BUNDLE_STAGING_DIR:
continue
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
config_path = extract_bundle(bundle_path, target_dir)
finally:
# Restore preserved dirs (idempotent) and clean staging
_restore_preserved_dirs(preserved, target_dir)
if staging.is_dir():
shutil.rmtree(staging)
return config_path
-2
View File
@@ -11,7 +11,6 @@
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LineComment,
LogStringLiteral,
MockObj,
@@ -79,7 +78,6 @@ from esphome.cpp_types import ( # noqa: F401
float_,
global_ns,
gpio_Flags,
int8,
int16,
int32,
int64,
@@ -1,29 +1,22 @@
#include "esphome/core/log.h"
#include "absolute_humidity.h"
namespace esphome::absolute_humidity {
namespace esphome {
namespace absolute_humidity {
static const char *const TAG{"absolute_humidity.sensor"};
static const char *const TAG = "absolute_humidity.sensor";
void AbsoluteHumidityComponent::setup() {
this->temperature_sensor_->add_on_state_callback([this](float state) {
this->temperature_ = state;
this->enable_loop();
});
ESP_LOGD(TAG, " Added callback for temperature '%s'", this->temperature_sensor_->get_name().c_str());
// Get initial value
this->temperature_sensor_->add_on_state_callback([this](float state) { this->temperature_callback_(state); });
if (this->temperature_sensor_->has_state()) {
this->temperature_ = this->temperature_sensor_->get_state();
this->temperature_callback_(this->temperature_sensor_->get_state());
}
this->humidity_sensor_->add_on_state_callback([this](float state) {
this->humidity_ = state;
this->enable_loop();
});
ESP_LOGD(TAG, " Added callback for relative humidity '%s'", this->humidity_sensor_->get_name().c_str());
// Get initial value
this->humidity_sensor_->add_on_state_callback([this](float state) { this->humidity_callback_(state); });
if (this->humidity_sensor_->has_state()) {
this->humidity_ = this->humidity_sensor_->get_state();
this->humidity_callback_(this->humidity_sensor_->get_state());
}
}
@@ -53,12 +46,14 @@ void AbsoluteHumidityComponent::dump_config() {
}
void AbsoluteHumidityComponent::loop() {
// Only run once
this->disable_loop();
if (!this->next_update_) {
return;
}
this->next_update_ = false;
// Ensure we have source data
const bool no_temperature{std::isnan(this->temperature_)};
const bool no_humidity{std::isnan(this->humidity_)};
const bool no_temperature = std::isnan(this->temperature_);
const bool no_humidity = std::isnan(this->humidity_);
if (no_temperature || no_humidity) {
if (no_temperature) {
ESP_LOGW(TAG, "No valid state from temperature sensor!");
@@ -72,9 +67,9 @@ void AbsoluteHumidityComponent::loop() {
}
// Convert to desired units
const float temperature_c{this->temperature_};
const float temperature_k{temperature_c + 273.15f};
const float hr{this->humidity_ / 100.0f};
const float temperature_c = this->temperature_;
const float temperature_k = temperature_c + 273.15;
const float hr = this->humidity_ / 100;
// Calculate saturation vapor pressure
float es;
@@ -95,9 +90,12 @@ void AbsoluteHumidityComponent::loop() {
}
// Calculate absolute humidity
const float absolute_humidity{vapor_density(es, hr, temperature_k)};
const float absolute_humidity = vapor_density(es, hr, temperature_k);
ESP_LOGD(TAG, "Saturation vapor pressure %f kPa, absolute humidity %f g/m³", es, absolute_humidity);
ESP_LOGD(TAG,
"Saturation vapor pressure %f kPa\n"
"Publishing absolute humidity %f g/m³",
es, absolute_humidity);
// Publish absolute humidity
this->status_clear_warning();
@@ -108,16 +106,16 @@ void AbsoluteHumidityComponent::loop() {
// More accurate than Tetens in normal meteorologic conditions
float AbsoluteHumidityComponent::es_buck(float temperature_c) {
float a, b, c, d;
if (temperature_c >= 0.0f) {
a = 0.61121f;
b = 18.678f;
c = 234.5f;
d = 257.14f;
if (temperature_c >= 0) {
a = 0.61121;
b = 18.678;
c = 234.5;
d = 257.14;
} else {
a = 0.61115f;
b = 18.678f;
c = 233.7f;
d = 279.82f;
a = 0.61115;
b = 18.678;
c = 233.7;
d = 279.82;
}
return a * expf((b - (temperature_c / c)) * (temperature_c / (d + temperature_c)));
}
@@ -125,14 +123,14 @@ float AbsoluteHumidityComponent::es_buck(float temperature_c) {
// Tetens equation (https://en.wikipedia.org/wiki/Tetens_equation)
float AbsoluteHumidityComponent::es_tetens(float temperature_c) {
float a, b;
if (temperature_c >= 0.0f) {
a = 17.27f;
b = 237.3f;
if (temperature_c >= 0) {
a = 17.27;
b = 237.3;
} else {
a = 21.875f;
b = 265.5f;
a = 21.875;
b = 265.5;
}
return 0.61078f * expf((a * temperature_c) / (temperature_c + b));
return 0.61078 * expf((a * temperature_c) / (temperature_c + b));
}
// Wobus equation
@@ -151,18 +149,18 @@ float AbsoluteHumidityComponent::es_wobus(float t) {
//
// Baker, Schlatter 17-MAY-1982 Original version.
constexpr float c0{+0.99999683e+00f};
constexpr float c1{-0.90826951e-02f};
constexpr float c2{+0.78736169e-04f};
constexpr float c3{-0.61117958e-06f};
constexpr float c4{+0.43884187e-08f};
constexpr float c5{-0.29883885e-10f};
constexpr float c6{+0.21874425e-12f};
constexpr float c7{-0.17892321e-14f};
constexpr float c8{+0.11112018e-16f};
constexpr float c9{-0.30994571e-19f};
const float p{c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))))};
return 0.61078f / powf(p, 8.0f);
const float c0 = +0.99999683e00;
const float c1 = -0.90826951e-02;
const float c2 = +0.78736169e-04;
const float c3 = -0.61117958e-06;
const float c4 = +0.43884187e-08;
const float c5 = -0.29883885e-10;
const float c6 = +0.21874425e-12;
const float c7 = -0.17892321e-14;
const float c8 = +0.11112018e-16;
const float c9 = -0.30994571e-19;
const float p = c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))));
return 0.61078 / pow(p, 8);
}
// From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/
@@ -173,10 +171,11 @@ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) {
// hr = relative humidity [0-1]
// ta = absolute temperature (K)
const float ea{hr * es * 1000.0f}; // vapor pressure of the air (Pa)
const float mw{18.01528f}; // molar mass of water (g⋅mol⁻¹)
const float r{8.31446261815324f}; // molar gas constant (J⋅K⁻¹)
const float ea = hr * es * 1000; // vapor pressure of the air (Pa)
const float mw = 18.01528; // molar mass of water (g⋅mol⁻¹)
const float r = 8.31446261815324; // molar gas constant (J⋅K⁻¹)
return (ea * mw) / (r * ta);
}
} // namespace esphome::absolute_humidity
} // namespace absolute_humidity
} // namespace esphome
@@ -3,7 +3,8 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::absolute_humidity {
namespace esphome {
namespace absolute_humidity {
/// Enum listing all implemented saturation vapor pressure equations.
enum SaturationVaporPressureEquation {
@@ -15,6 +16,8 @@ enum SaturationVaporPressureEquation {
/// This class implements calculation of absolute humidity from temperature and relative humidity.
class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
public:
AbsoluteHumidityComponent() = default;
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
void set_equation(SaturationVaporPressureEquation equation) { this->equation_ = equation; }
@@ -24,6 +27,15 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
void loop() override;
protected:
void temperature_callback_(float state) {
this->next_update_ = true;
this->temperature_ = state;
}
void humidity_callback_(float state) {
this->next_update_ = true;
this->humidity_ = state;
}
/** Buck equation for saturation vapor pressure in kPa.
*
* @param temperature_c Air temperature in °C.
@@ -45,15 +57,19 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
* @param es Saturation vapor pressure in kPa.
* @param hr Relative humidity 0 to 1.
* @param ta Absolute temperature in K.
* @param heater_duration The duration in ms that the heater should turn on for when measuring.
*/
static float vapor_density(float es, float hr, float ta);
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
bool next_update_{false};
float temperature_{NAN};
float humidity_{NAN};
SaturationVaporPressureEquation equation_;
};
} // namespace esphome::absolute_humidity
} // namespace absolute_humidity
} // namespace esphome
+13 -26
View File
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
this->zero_cross_pin_->setup();
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
this->zero_cross_interrupt_type_);
gpio::INTERRUPT_FALLING_EDGE);
}
#ifdef USE_ESP8266
@@ -199,19 +199,12 @@ void AcDimmer::setup() {
setTimer1Callback(&timer_interrupt);
#endif
#ifdef USE_ESP32
if (dimmer_timer == nullptr) {
dimmer_timer = timer_begin(TIMER_FREQUENCY_HZ);
if (dimmer_timer == nullptr) {
ESP_LOGE(TAG, "Failed to create GPTimer for AC dimmer");
this->mark_failed();
return;
}
timer_attach_interrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr);
// For ESP32, we can't use dynamic interval calculation because the timerX functions
// are not callable from ISR (placed in flash storage).
// Here we just use an interrupt firing every 50 µs.
timer_alarm(dimmer_timer, TIMER_INTERVAL_US, true, 0);
}
dimmer_timer = timer_begin(TIMER_FREQUENCY_HZ);
timer_attach_interrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr);
// For ESP32, we can't use dynamic interval calculation because the timerX functions
// are not callable from ISR (placed in flash storage).
// Here we just use an interrupt firing every 50 µs.
timer_alarm(dimmer_timer, TIMER_INTERVAL_US, true, 0);
#endif
}
@@ -226,25 +219,19 @@ void AcDimmer::write_state(float state) {
void AcDimmer::dump_config() {
ESP_LOGCONFIG(TAG,
"AcDimmer:\n"
" Min Power: %.1f%%\n"
" Init with half cycle: %s",
" Min Power: %.1f%%\n"
" Init with half cycle: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
LOG_PIN(" Output Pin: ", this->gate_pin_);
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
} else {
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
}
if (method_ == DIM_METHOD_LEADING_PULSE) {
ESP_LOGCONFIG(TAG, " Method: leading pulse");
ESP_LOGCONFIG(TAG, " Method: leading pulse");
} else if (method_ == DIM_METHOD_LEADING) {
ESP_LOGCONFIG(TAG, " Method: leading");
ESP_LOGCONFIG(TAG, " Method: leading");
} else {
ESP_LOGCONFIG(TAG, " Method: trailing");
ESP_LOGCONFIG(TAG, " Method: trailing");
}
LOG_FLOAT_OUTPUT(this);
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
}
-2
View File
@@ -48,7 +48,6 @@ class AcDimmer : public output::FloatOutput, public Component {
void dump_config() override;
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
void set_method(DimMethod method) { method_ = method; }
@@ -57,7 +56,6 @@ class AcDimmer : public output::FloatOutput, public Component {
InternalGPIOPin *gate_pin_;
InternalGPIOPin *zero_cross_pin_;
gpio::InterruptType zero_cross_interrupt_type_;
AcDimmerDataStore store_;
bool init_with_half_cycle_;
DimMethod method_;
-14
View File
@@ -7,8 +7,6 @@ from esphome.core import CORE
CODEOWNERS = ["@glmnet"]
gpio_ns = cg.esphome_ns.namespace("gpio")
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
@@ -19,26 +17,15 @@ DIM_METHODS = {
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
}
ZC_INTERRUPT_TYPES = {
"RISING": gpio_ns.INTERRUPT_RISING_EDGE,
"FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
"ANY": gpio_ns.INTERRUPT_ANY_EDGE,
}
CONF_GATE_PIN = "gate_pin"
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
ZC_INTERRUPT_TYPES, upper=True, space="_"
),
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
DIM_METHODS, upper=True, space="_"
@@ -67,6 +54,5 @@ async def to_code(config):
cg.add(var.set_gate_pin(pin))
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
cg.add(var.set_zero_cross_pin(pin))
cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
cg.add(var.set_method(config[CONF_METHOD]))
+1 -2
View File
@@ -22,8 +22,7 @@ namespace adc {
#ifdef USE_ESP32
// clang-format off
#if ESP_IDF_VERSION_MAJOR >= 6 || \
(ESP_IDF_VERSION_MAJOR == 5 && \
#if (ESP_IDF_VERSION_MAJOR == 5 && \
((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \
(ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \
(ESP_IDF_VERSION_MINOR >= 2)) \
+3 -7
View File
@@ -2,7 +2,6 @@
#include "adc_sensor.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome {
namespace adc {
@@ -347,8 +346,7 @@ float ADCSensor::sample_autorange_() {
ESP_LOGVV(TAG, "Autorange summary:");
ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0);
ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0);
ESP_LOGVV(TAG, " Coefficients: c12=%" PRIu32 ", c6=%" PRIu32 ", c2=%" PRIu32 ", c0=%" PRIu32 ", sum=%" PRIu32, c12,
c6, c2, c0, csum);
ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum);
if (csum == 0) {
ESP_LOGE(TAG, "Invalid weight sum in autorange calculation");
@@ -356,10 +354,8 @@ float ADCSensor::sample_autorange_() {
}
const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum;
ESP_LOGV(TAG,
"Autorange final: (%.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 ")/%" PRIu32
" = %.6fV",
mv12, c12, mv6, c6, mv2, c2, mv0, c0, csum, final_result);
ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0,
c0, csum, final_result);
return final_result;
}
@@ -8,13 +8,6 @@
#endif // CYW43_USES_VSYS_PIN
#include <hardware/adc.h>
// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h),
// but the Arduino framework's config_autogen.h includes a generic board header
// that doesn't define it. Provide the standard value (pin 29) as a fallback.
#ifndef PICO_VSYS_PIN
#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage)
#endif
namespace esphome {
namespace adc {
+1 -14
View File
@@ -2,11 +2,7 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
require_adc_oneshot_iram,
)
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -28,7 +24,6 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from . import (
ATTENUATION_MODES,
@@ -70,13 +65,6 @@ def validate_config(config):
return config
def _require_adc_iram(config: ConfigType) -> ConfigType:
"""Register ADC oneshot IRAM requirement during config validation."""
if CORE.is_esp32:
require_adc_oneshot_iram()
return config
ADCSensor = adc_ns.class_(
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
)
@@ -107,7 +95,6 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s")),
validate_config,
_require_adc_iram,
)
CONF_ADC_CHANNEL_ID = "adc_channel_id"
@@ -58,10 +58,7 @@ void HOT AddressableLightDisplay::draw_absolute_pixel_internal(int x, int y, Col
if (this->pixel_mapper_f_.has_value()) {
// Params are passed by reference, so they may be modified in call.
int index = (*this->pixel_mapper_f_)(x, y);
if (index < 0 || static_cast<size_t>(index) >= this->addressable_light_buffer_.size())
return;
this->addressable_light_buffer_[index] = color;
this->addressable_light_buffer_[(*this->pixel_mapper_f_)(x, y)] = color;
} else {
this->addressable_light_buffer_[y * this->get_width_internal() + x] = color;
}
@@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer {
// - Save the current effect index.
this->last_effect_index_ = light_state_->get_current_effect_index();
// - Disable any current effect.
light_state_->make_call().set_effect(uint32_t{0}).perform();
light_state_->make_call().set_effect(0).perform();
}
}
enabled_ = enabled;
+3 -3
View File
@@ -121,7 +121,7 @@ void ADE7880::update() {
this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) {
return chan->forward_active_energy_total += val / 14400.0f;
});
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) {
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, AFWATTHR, [&chan](float val) {
return chan->reverse_active_energy_total += val / 14400.0f;
});
}
@@ -137,7 +137,7 @@ void ADE7880::update() {
this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) {
return chan->forward_active_energy_total += val / 14400.0f;
});
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) {
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BFWATTHR, [&chan](float val) {
return chan->reverse_active_energy_total += val / 14400.0f;
});
}
@@ -153,7 +153,7 @@ void ADE7880::update() {
this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) {
return chan->forward_active_energy_total += val / 14400.0f;
});
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) {
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CFWATTHR, [&chan](float val) {
return chan->reverse_active_energy_total += val / 14400.0f;
});
}
@@ -85,9 +85,6 @@ constexpr uint16_t CWATTHR = 0xE402;
constexpr uint16_t AFWATTHR = 0xE403;
constexpr uint16_t BFWATTHR = 0xE404;
constexpr uint16_t CFWATTHR = 0xE405;
constexpr uint16_t ARWATTHR = 0xE406;
constexpr uint16_t BRWATTHR = 0xE407;
constexpr uint16_t CRWATTHR = 0xE408;
constexpr uint16_t AFVARHR = 0xE409;
constexpr uint16_t BFVARHR = 0xE40A;
constexpr uint16_t CFVARHR = 0xE40B;
@@ -8,9 +8,6 @@ namespace ade7953_base {
static const char *const TAG = "ade7953";
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
static const float ADE_POWER_FACTOR = 154.0f;
static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600;
@@ -21,12 +18,7 @@ void ADE7953::setup() {
// The chip might take up to 100ms to initialise
this->set_timeout(100, [this]() {
// Lock communication interface (SPI or I2C)
uint16_t config_v = CONFIG_DEFAULT;
this->ade_read_16(CONFIG_16, &config_v);
config_v &= static_cast<uint16_t>(~CONFIG_LOCK_BIT); // Clear the lock bit
this->ade_write_16(CONFIG_16, config_v);
// Configure optimum settings according to datasheet
// this->ade_write_8(0x0010, 0x04);
this->ade_write_8(0x00FE, 0xAD);
this->ade_write_16(0x0120, 0x0030);
// Set gains
+13 -17
View File
@@ -9,35 +9,31 @@
namespace esphome {
namespace ade7953_base {
static constexpr uint8_t PGA_V_8 =
static const uint8_t PGA_V_8 =
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
static constexpr uint8_t PGA_IA_8 =
static const uint8_t PGA_IA_8 =
0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0])
static constexpr uint8_t PGA_IB_8 =
static const uint8_t PGA_IB_8 =
0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0])
static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register
static constexpr uint16_t AIGAIN_32 =
static const uint32_t AIGAIN_32 =
0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit)
static constexpr uint16_t AVGAIN_32 =
0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t AWGAIN_32 =
static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t AWGAIN_32 =
0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit)
static constexpr uint16_t AVARGAIN_32 =
static const uint32_t AVARGAIN_32 =
0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit)
static constexpr uint16_t AVAGAIN_32 =
static const uint32_t AVAGAIN_32 =
0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit)
static constexpr uint16_t BIGAIN_32 =
static const uint32_t BIGAIN_32 =
0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit)
static constexpr uint16_t BVGAIN_32 =
0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t BWGAIN_32 =
static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t BWGAIN_32 =
0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit)
static constexpr uint16_t BVARGAIN_32 =
static const uint32_t BVARGAIN_32 =
0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit)
static constexpr uint16_t BVAGAIN_32 =
static const uint32_t BVAGAIN_32 =
0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit)
class ADE7953 : public PollingComponent, public sensor::Sensor {
@@ -7,9 +7,6 @@ namespace ade7953_spi {
static const char *const TAG = "ade7953";
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
void AdE7953Spi::setup() {
this->spi_setup();
ade7953_base::ADE7953::setup();
@@ -35,9 +32,6 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) {
this->write_byte16(reg);
this->transfer_byte(0);
this->write_byte16(value);
if (reg == ade7953_base::CONFIG_16) {
delayMicroseconds(CONFIG_LOCK_SETTLE_US);
}
this->disable();
return false;
}
+1 -1
View File
@@ -12,7 +12,7 @@ namespace esphome {
namespace ade7953_spi {
class AdE7953Spi : public ade7953_base::ADE7953,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_TRAILING,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_1MHZ> {
public:
void setup() override;
+13 -2
View File
@@ -173,8 +173,19 @@ float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1
}
if (resolution == ADS1015_12_BITS) {
// ADS1015 returns 12-bit value left-justified in 16 bits; shift right and sign-extend
raw_conversion = static_cast<uint16_t>(static_cast<int16_t>(raw_conversion) >> (16 - ADS1015_12_BITS));
bool negative = (raw_conversion >> 15) == 1;
// shift raw_conversion as it's only 12-bits, left justified
raw_conversion = raw_conversion >> (16 - ADS1015_12_BITS);
// check if number was negative in order to keep the sign
if (negative) {
// the number was negative
// 1) set the negative bit back
raw_conversion |= 0x8000;
// 2) reset the former (shifted) negative bit
raw_conversion &= 0xF7FF;
}
}
auto signed_conversion = static_cast<int16_t>(raw_conversion);
+5 -9
View File
@@ -12,15 +12,11 @@ CONF_ADS1118_ID = "ads1118_id"
ads1118_ns = cg.esphome_ns.namespace("ads1118")
ADS1118 = ads1118_ns.class_("ADS1118", cg.Component, spi.SPIDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ADS1118),
}
)
.extend(spi.spi_device_schema(cs_pin_required=True))
.extend(cv.COMPONENT_SCHEMA)
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ADS1118),
}
).extend(spi.spi_device_schema(cs_pin_required=True))
async def to_code(config):
+5 -11
View File
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AGS10Component),
cv.Required(CONF_TVOC): sensor.sensor_schema(
cv.Optional(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
@@ -92,12 +92,11 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
"ags10.new_i2c_address",
AGS10NewI2cAddressAction,
AGS10_NEW_I2C_ADDRESS_SCHEMA,
synchronous=True,
)
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
address = await cg.templatable(config[CONF_ADDRESS], args, int)
cg.add(var.set_new_address(address))
return var
@@ -112,9 +111,7 @@ AGS10_SET_ZERO_POINT_ACTION_MODE = {
AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(AGS10Component),
cv.Required(CONF_MODE): cv.templatable(
cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True)
),
cv.Required(CONF_MODE): cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True),
cv.Optional(CONF_VALUE, default=0xFFFF): cv.templatable(cv.uint16_t),
},
)
@@ -124,15 +121,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
"ags10.set_zero_point",
AGS10SetZeroPointAction,
AGS10_SET_ZERO_POINT_SCHEMA,
synchronous=True,
)
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
mode = await cg.templatable(
config.get(CONF_MODE), args, AGS10SetZeroPointActionMode
)
mode = await cg.templatable(config.get(CONF_MODE), args, enumerate)
cg.add(var.set_mode(mode))
value = await cg.templatable(config[CONF_VALUE], args, cg.uint16)
value = await cg.templatable(config[CONF_VALUE], args, int)
cg.add(var.set_value(value))
return var
+2 -5
View File
@@ -34,16 +34,13 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
@automation.register_action(
"aic3204.set_auto_mute_mode",
SetAutoMuteAction,
SET_AUTO_MUTE_ACTION_SCHEMA,
synchronous=True,
"aic3204.set_auto_mute_mode", SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA
)
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8)
template_ = await cg.templatable(config.get(CONF_MODE), args, int)
cg.add(var.set_auto_mute_mode(template_))
return var
+133 -111
View File
@@ -10,14 +10,11 @@ from esphome.const import (
CONF_ID,
CONF_MQTT_ID,
CONF_ON_STATE,
CONF_TRIGGER_ID,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@grahambrown11", "@hwstar"]
@@ -37,9 +34,39 @@ CONF_ON_READY = "on_ready"
alarm_control_panel_ns = cg.esphome_ns.namespace("alarm_control_panel")
AlarmControlPanel = alarm_control_panel_ns.class_("AlarmControlPanel", cg.EntityBase)
StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder")
StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder")
AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState")
StateTrigger = alarm_control_panel_ns.class_(
"StateTrigger", automation.Trigger.template()
)
TriggeredTrigger = alarm_control_panel_ns.class_(
"TriggeredTrigger", automation.Trigger.template()
)
ClearedTrigger = alarm_control_panel_ns.class_(
"ClearedTrigger", automation.Trigger.template()
)
ArmingTrigger = alarm_control_panel_ns.class_(
"ArmingTrigger", automation.Trigger.template()
)
PendingTrigger = alarm_control_panel_ns.class_(
"PendingTrigger", automation.Trigger.template()
)
ArmedHomeTrigger = alarm_control_panel_ns.class_(
"ArmedHomeTrigger", automation.Trigger.template()
)
ArmedNightTrigger = alarm_control_panel_ns.class_(
"ArmedNightTrigger", automation.Trigger.template()
)
ArmedAwayTrigger = alarm_control_panel_ns.class_(
"ArmedAwayTrigger", automation.Trigger.template()
)
DisarmedTrigger = alarm_control_panel_ns.class_(
"DisarmedTrigger", automation.Trigger.template()
)
ChimeTrigger = alarm_control_panel_ns.class_(
"ChimeTrigger", automation.Trigger.template()
)
ReadyTrigger = alarm_control_panel_ns.class_(
"ReadyTrigger", automation.Trigger.template()
)
ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action)
ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action)
@@ -62,17 +89,61 @@ _ALARM_CONTROL_PANEL_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt.MQTTAlarmControlPanelComponent
),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}),
cv.Optional(CONF_ON_ARMING): automation.validate_automation({}),
cv.Optional(CONF_ON_PENDING): automation.validate_automation({}),
cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation({}),
cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation({}),
cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation({}),
cv.Optional(CONF_ON_DISARMED): automation.validate_automation({}),
cv.Optional(CONF_ON_CLEARED): automation.validate_automation({}),
cv.Optional(CONF_ON_CHIME): automation.validate_automation({}),
cv.Optional(CONF_ON_READY): automation.validate_automation({}),
cv.Optional(CONF_ON_STATE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
}
),
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TriggeredTrigger),
}
),
cv.Optional(CONF_ON_ARMING): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmingTrigger),
}
),
cv.Optional(CONF_ON_PENDING): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PendingTrigger),
}
),
cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedHomeTrigger),
}
),
cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedNightTrigger),
}
),
cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedAwayTrigger),
}
),
cv.Optional(CONF_ON_DISARMED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisarmedTrigger),
}
),
cv.Optional(CONF_ON_CLEARED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClearedTrigger),
}
),
cv.Optional(CONF_ON_CHIME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ChimeTrigger),
}
),
cv.Optional(CONF_ON_READY): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReadyTrigger),
}
),
}
)
)
@@ -115,66 +186,41 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder
),
automation.CallbackAutomation(
CONF_ON_TRIGGERED,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_TRIGGERED
),
),
automation.CallbackAutomation(
CONF_ON_ARMING,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING),
),
automation.CallbackAutomation(
CONF_ON_PENDING,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_PENDING
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_HOME,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_HOME
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_NIGHT,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_NIGHT
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_AWAY,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_AWAY
),
),
automation.CallbackAutomation(
CONF_ON_DISARMED,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_DISARMED
),
),
automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"),
automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"),
automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"),
)
@setup_entity("alarm_control_panel")
async def setup_alarm_control_panel_core_(var, config):
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
await setup_entity(var, config, "alarm_control_panel")
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_TRIGGERED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_ARMING, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_PENDING, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_ARMED_HOME, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_ARMED_NIGHT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_ARMED_AWAY, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DISARMED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_CLEARED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_CHIME, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_READY, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if mqtt_id := config.get(CONF_MQTT_ID):
@@ -185,7 +231,7 @@ async def setup_alarm_control_panel_core_(var, config):
async def register_alarm_control_panel(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
queue_entity_register("alarm_control_panel", config)
cg.add(cg.App.register_alarm_control_panel(var))
CORE.register_platform_component("alarm_control_panel", var)
await setup_alarm_control_panel_core_(var, config)
@@ -197,10 +243,7 @@ async def new_alarm_control_panel(config, *args):
@automation.register_action(
"alarm_control_panel.arm_away",
ArmAwayAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.arm_away", ArmAwayAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_arm_away_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -212,10 +255,7 @@ async def alarm_action_arm_away_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.arm_home",
ArmHomeAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.arm_home", ArmHomeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_arm_home_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -227,10 +267,7 @@ async def alarm_action_arm_home_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.arm_night",
ArmNightAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.arm_night", ArmNightAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_arm_night_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -242,10 +279,7 @@ async def alarm_action_arm_night_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.disarm",
DisarmAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.disarm", DisarmAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_disarm_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -257,10 +291,7 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.pending",
PendingAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_pending_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -268,10 +299,7 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.triggered",
TriggeredAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_trigger_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -279,10 +307,7 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.chime",
ChimeAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
async def alarm_action_chime_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
@@ -290,10 +315,7 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args):
@automation.register_action(
"alarm_control_panel.ready",
ReadyAction,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
synchronous=True,
"alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
)
@automation.register_condition(
"alarm_control_panel.ready",
@@ -31,12 +31,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
this->last_update_ = millis();
if (state != this->current_state_) {
auto prev_state = this->current_state_;
ESP_LOGV(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
ESP_LOGD(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
LOG_STR_ARG(alarm_control_panel_state_to_string(state)),
LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state)));
this->current_state_ = state;
// Single state callback - listeners receive the new state as an argument
this->state_callback_.call(state);
// Single state callback - triggers check get_state() for specific states
this->state_callback_.call();
#if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_alarm_control_panel_update(this);
#endif
@@ -51,6 +51,22 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
}
}
void AlarmControlPanel::add_on_state_callback(std::function<void()> &&callback) {
this->state_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_cleared_callback(std::function<void()> &&callback) {
this->cleared_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_chime_callback(std::function<void()> &&callback) {
this->chime_callback_.add(std::move(callback));
}
void AlarmControlPanel::add_on_ready_callback(std::function<void()> &&callback) {
this->ready_callback_.add(std::move(callback));
}
void AlarmControlPanel::arm_with_code_(AlarmControlPanelCall &(AlarmControlPanelCall::*arm_method)(),
const char *code) {
auto call = this->make_call();
@@ -37,24 +37,25 @@ class AlarmControlPanel : public EntityBase {
*
* @param callback The callback function
*/
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
void add_on_state_callback(std::function<void()> &&callback);
/** Add a callback for when the state of the alarm_control_panel clears from triggered. */
template<typename F> void add_on_cleared_callback(F &&callback) {
this->cleared_callback_.add(std::forward<F>(callback));
}
/** Add a callback for when the state of the alarm_control_panel clears from triggered
*
* @param callback The callback function
*/
void add_on_cleared_callback(std::function<void()> &&callback);
/** Add a callback for when a chime zone goes from closed to open. */
template<typename F> void add_on_chime_callback(F &&callback) {
this->chime_callback_.add(std::forward<F>(callback));
}
/** Add a callback for when a chime zone goes from closed to open
*
* @param callback The callback function
*/
void add_on_chime_callback(std::function<void()> &&callback);
/** Add a callback for when a ready state changes. */
template<typename F> void add_on_ready_callback(F &&callback) {
this->ready_callback_.add(std::forward<F>(callback));
}
/** Add a callback for when a ready state changes
*
* @param callback The callback function
*/
void add_on_ready_callback(std::function<void()> &&callback);
/** A numeric representation of the supported features as per HomeAssistant
*
@@ -145,8 +146,8 @@ class AlarmControlPanel : public EntityBase {
uint32_t last_update_;
// the call control function
virtual void control(const AlarmControlPanelCall &call) = 0;
// state callback - passes the new state to listeners
LazyCallbackManager<void(AlarmControlPanelState)> state_callback_{};
// state callback - triggers check get_state() for specific state
LazyCallbackManager<void()> state_callback_{};
// clear callback - fires when leaving TRIGGERED state
LazyCallbackManager<void()> cleared_callback_{};
// chime callback
@@ -12,14 +12,7 @@ AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent
AlarmControlPanelCall &AlarmControlPanelCall::set_code(const char *code) {
if (code != nullptr) {
return this->set_code(code, strlen(code));
}
return *this;
}
AlarmControlPanelCall &AlarmControlPanelCall::set_code(const char *code, size_t len) {
if (code != nullptr) {
this->code_ = std::string(code, len);
this->code_ = std::string(code);
}
return *this;
}
@@ -15,8 +15,7 @@ class AlarmControlPanelCall {
AlarmControlPanelCall(AlarmControlPanel *parent);
AlarmControlPanelCall &set_code(const char *code);
AlarmControlPanelCall &set_code(const char *code, size_t len);
AlarmControlPanelCall &set_code(const std::string &code) { return this->set_code(code.c_str(), code.size()); }
AlarmControlPanelCall &set_code(const std::string &code) { return this->set_code(code.c_str()); }
AlarmControlPanelCall &arm_away();
AlarmControlPanelCall &arm_home();
AlarmControlPanelCall &arm_night();
@@ -5,27 +5,60 @@
namespace esphome::alarm_control_panel {
/// Callback forwarder that triggers an Automation<> on any state change.
/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
struct StateAnyForwarder {
Automation<> *automation;
void operator()(AlarmControlPanelState /*state*/) const { this->automation->trigger(); }
};
/// Callback forwarder that triggers an Automation<> only when the alarm enters a specific state.
/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
template<AlarmControlPanelState State> struct StateEnterForwarder {
Automation<> *automation;
void operator()(AlarmControlPanelState state) const {
if (state == State)
this->automation->trigger();
/// Trigger on any state change
class StateTrigger : public Trigger<> {
public:
explicit StateTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_state_callback([this]() { this->trigger(); });
}
};
static_assert(sizeof(StateAnyForwarder) <= sizeof(void *));
static_assert(std::is_trivially_copyable_v<StateAnyForwarder>);
static_assert(sizeof(StateEnterForwarder<ACP_STATE_TRIGGERED>) <= sizeof(void *));
static_assert(std::is_trivially_copyable_v<StateEnterForwarder<ACP_STATE_TRIGGERED>>);
/// Template trigger that fires when entering a specific state
template<AlarmControlPanelState State> class StateEnterTrigger : public Trigger<> {
public:
explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {
alarm_control_panel->add_on_state_callback([this]() {
if (this->alarm_control_panel_->get_state() == State)
this->trigger();
});
}
protected:
AlarmControlPanel *alarm_control_panel_;
};
// Type aliases for state-specific triggers
using TriggeredTrigger = StateEnterTrigger<ACP_STATE_TRIGGERED>;
using ArmingTrigger = StateEnterTrigger<ACP_STATE_ARMING>;
using PendingTrigger = StateEnterTrigger<ACP_STATE_PENDING>;
using ArmedHomeTrigger = StateEnterTrigger<ACP_STATE_ARMED_HOME>;
using ArmedNightTrigger = StateEnterTrigger<ACP_STATE_ARMED_NIGHT>;
using ArmedAwayTrigger = StateEnterTrigger<ACP_STATE_ARMED_AWAY>;
using DisarmedTrigger = StateEnterTrigger<ACP_STATE_DISARMED>;
/// Trigger when leaving TRIGGERED state (alarm cleared)
class ClearedTrigger : public Trigger<> {
public:
explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); });
}
};
/// Trigger on chime event (zone opened while disarmed)
class ChimeTrigger : public Trigger<> {
public:
explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); });
}
};
/// Trigger on ready state change
class ReadyTrigger : public Trigger<> {
public:
explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) {
alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); });
}
};
template<typename... Ts> class ArmAwayAction : public Action<Ts...> {
public:
+1 -1
View File
@@ -125,7 +125,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc
this->current_sensor_->publish_state(NAN);
if (this->speed_sensor_ != nullptr)
this->speed_sensor_->publish_state(NAN);
if (this->voltage_sensor_ != nullptr)
if (this->speed_sensor_ != nullptr)
this->voltage_sensor_->publish_state(NAN);
break;
}
-13
View File
@@ -9,10 +9,6 @@ from esphome.const import (
CONF_POWER,
CONF_SPEED,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_POWER,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CUBIC_METER_PER_HOUR,
UNIT_METER,
@@ -31,35 +27,26 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_FLOW): sensor.sensor_schema(
unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HEAD): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_SPEED): sensor.sensor_schema(
unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
+2 -3
View File
@@ -63,9 +63,8 @@ void Am43Component::control(const CoverCall &call) {
ESP_LOGW(TAG, "[%s] Error writing stop command to device, error = %d", this->get_name().c_str(), status);
}
}
auto opt_pos = call.get_position();
if (opt_pos.has_value()) {
auto pos = *opt_pos;
if (call.get_position().has_value()) {
auto pos = *call.get_position();
if (this->invert_position_)
pos = 1 - pos;
@@ -8,7 +8,6 @@ from esphome.const import (
DEVICE_CLASS_BATTERY,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_BRIGHTNESS_5,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
@@ -27,13 +26,11 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_BATTERY,
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
+1 -1
View File
@@ -35,7 +35,7 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent
uint8_t current_sensor_;
// The AM43 often gets into a state where it spams loads of battery update
// notifications. Here we will limit to no more than every 10s.
uint32_t last_battery_update_;
uint8_t last_battery_update_;
};
} // namespace am43
@@ -1,6 +1,5 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/sensor/sensor.h"
@@ -19,8 +18,8 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
protected:
sensor::Sensor *sensor_{nullptr};
TemplatableFn<float> upper_threshold_{};
TemplatableFn<float> lower_threshold_{};
TemplatableValue<float> upper_threshold_{};
TemplatableValue<float> lower_threshold_{};
bool raw_state_{false}; // Pre-filter state for hysteresis logic
};
@@ -40,10 +40,10 @@ async def to_code(config):
cg.add(var.set_sensor(sens))
if isinstance(config[CONF_THRESHOLD], dict):
lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_)
upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], cg.float_)
lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], float)
upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], float)
else:
lower = await cg.templatable(config[CONF_THRESHOLD], [], cg.float_)
lower = await cg.templatable(config[CONF_THRESHOLD], [], float)
upper = lower
cg.add(var.set_upper_threshold(upper))
cg.add(var.set_lower_threshold(lower))
+3 -9
View File
@@ -69,15 +69,9 @@ SET_FRAME_SCHEMA = cv.Schema(
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
@automation.register_action("animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA)
@automation.register_action("animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA)
@automation.register_action("animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
+1 -6
View File
@@ -62,12 +62,7 @@ void Animation::set_frame(int frame) {
}
void Animation::update_data_start_() {
uint32_t image_size = this->get_width_stride() * this->height_;
// RGB565 with an alpha channel stores the alpha plane immediately after the RGB
// plane within each frame, so the per-frame stride includes the alpha bytes.
if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
image_size += static_cast<uint32_t>(this->width_) * this->height_;
}
const uint32_t image_size = this->get_width_stride() * this->height_;
this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_;
}
+11 -14
View File
@@ -24,9 +24,8 @@ void Anova::loop() {
}
void Anova::control(const ClimateCall &call) {
auto mode_val = call.get_mode();
if (mode_val.has_value()) {
ClimateMode mode = *mode_val;
if (call.get_mode().has_value()) {
ClimateMode mode = *call.get_mode();
AnovaPacket *pkt;
switch (mode) {
case climate::CLIMATE_MODE_OFF:
@@ -46,9 +45,8 @@ void Anova::control(const ClimateCall &call) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
auto target_temp = call.get_target_temperature();
if (target_temp.has_value()) {
auto *pkt = this->codec_->get_set_target_temp_request(*target_temp);
if (call.get_target_temperature().has_value()) {
auto *pkt = this->codec_->get_set_target_temp_request(*call.get_target_temperature());
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
@@ -69,8 +67,10 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *chr = this->parent_->get_characteristic(ANOVA_SERVICE_UUID, ANOVA_CHARACTERISTIC_UUID);
if (chr == nullptr) {
ESP_LOGW(TAG, "[%s] No control service found at device, not an Anova..?", this->get_name().c_str());
ESP_LOGW(TAG, "[%s] Note, this component does not currently support Anova Nano.", this->get_name().c_str());
ESP_LOGW(TAG,
"[%s] No control service found at device, not an Anova..?\n"
"[%s] Note, this component does not currently support Anova Nano.",
this->get_name().c_str(), this->get_name().c_str());
break;
}
this->char_handle_ = chr->handle;
@@ -144,12 +144,9 @@ void Anova::update() {
return;
if (this->current_request_ < 2) {
AnovaPacket *pkt;
if (this->current_request_ == 0) {
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
} else {
pkt = this->codec_->get_read_device_status_request();
}
auto *pkt = this->codec_->get_read_device_status_request();
if (this->current_request_ == 0)
this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
+2 -4
View File
@@ -2,8 +2,6 @@
#include <cstdio>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
namespace esphome {
namespace anova {
@@ -107,14 +105,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) {
}
case READ_TARGET_TEMPERATURE:
case SET_TARGET_TEMPERATURE: {
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
if (this->fahrenheit_)
this->target_temp_ = ftoc(this->target_temp_);
this->has_target_temp_ = true;
break;
}
case READ_CURRENT_TEMPERATURE: {
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
if (this->fahrenheit_)
this->current_temp_ = ftoc(this->current_temp_);
this->has_current_temp_ = true;
+4 -4
View File
@@ -251,11 +251,11 @@ void APDS9960::read_gesture_data_() {
uint8_t buf[128];
for (uint8_t pos = 0; pos < fifo_level * 4; pos += 32) {
// Read in 32-byte chunks due to ESP8266 I2C buffer limit.
// Always read from 0xFC — the FIFO auto-increments through 0xFC-0xFF
// and advances its internal pointer after every 4th byte.
// The ESP's i2c driver has a limited buffer size.
// This way of retrieving the data should be wrong according to the datasheet
// but it seems to work.
uint8_t read = std::min(32, fifo_level * 4 - pos);
APDS9960_WARNING_CHECK(this->read_bytes(0xFC, buf + pos, read), "Reading FIFO buffer failed.");
APDS9960_WARNING_CHECK(this->read_bytes(0xFC + pos, buf + pos, read), "Reading FIFO buffer failed.");
}
if (millis() - this->gesture_start_ > 500) {
+22 -53
View File
@@ -76,7 +76,7 @@ SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
"bool": cg.bool_,
"int": cg.int32,
"float": cg.float_,
"string": cg.StringRef,
"string": cg.std_string,
"bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"),
"int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"),
"float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"),
@@ -233,8 +233,8 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
# API needs 1 listening socket + typically 3 concurrent client connections
# (not max_connections, which is the upper limit rarely reached)
socket.consume_sockets(3, "api")(config)
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
sockets_needed = 1 + 3
socket.consume_sockets(sockets_needed, "api")(config)
return config
@@ -291,22 +291,21 @@ CONFIG_SCHEMA = cv.All(
cv.SplitDefault(
CONF_MAX_CONNECTIONS,
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
esp32=5, # 520KB RAM available
esp32=8, # 520KB RAM available
rp2040=4, # 264KB RAM but LWIP constraints
bk72xx=5, # Moderate RAM
rtl87xx=5, # Moderate RAM
bk72xx=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
host=8, # Abundant resources
ln882x=5, # Moderate RAM
ln882x=8, # Moderate RAM
): cv.int_range(min=1, max=20),
# Maximum queued send buffers per connection before dropping connection
# Each buffer uses ~8-12 bytes overhead plus actual message size
# Platform defaults based on available RAM and typical message rates:
# CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo
cv.SplitDefault(
CONF_MAX_SEND_QUEUE,
esp8266=4, # Limited RAM, need to fail fast
esp8266=5, # Limited RAM, need to fail fast
esp32=8, # More RAM, can buffer more
rp2040=8, # Moderate RAM
rp2040=5, # Limited RAM
bk72xx=8, # Moderate RAM
nrf52=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
@@ -336,7 +335,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
if CONF_LISTEN_BACKLOG in config:
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
if CONF_MAX_CONNECTIONS in config:
cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS]))
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
@@ -380,18 +380,9 @@ async def to_code(config: ConfigType) -> None:
if is_optional:
func_args.append((cg.bool_, "return_response"))
# Check if action chain has non-synchronous actions that would make
# non-owning StringRef dangle (rx_buf_ reused after delay)
has_non_synchronous = automation.has_non_synchronous_actions(
conf.get(CONF_THEN, [])
)
service_arg_names: list[str] = []
for name, var_ in conf[CONF_VARIABLES].items():
native = SERVICE_ARG_NATIVE_TYPES[var_]
# Fall back to std::string for string args if non-synchronous actions exist
if has_non_synchronous and native is cg.StringRef:
native = cg.std_string
service_template_args.append(native)
func_args.append((native, name))
service_arg_names.append(name)
@@ -453,10 +444,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.11")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
cg.add_library("esphome/noise-c", "0.1.10")
else:
cg.add_define("USE_API_PLAINTEXT")
@@ -521,13 +509,11 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
"homeassistant.action",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
synchronous=True,
)
@automation.register_action(
"homeassistant.service",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_service_to_code(
config: ConfigType,
@@ -538,31 +524,24 @@ async def homeassistant_service_to_code(
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
templ = await cg.templatable(config[CONF_ACTION], args, None)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
cg.add(var.add_variable(key, templ))
if on_error := config.get(CONF_ON_ERROR):
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
@@ -625,37 +604,29 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
"homeassistant.event",
HomeAssistantServiceCallAction,
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_event_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
templ = await cg.templatable(config[CONF_EVENT], args, None)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
cg.add(var.add_variable(key, templ))
return var
@@ -673,17 +644,16 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
"homeassistant.tag_scanned",
HomeAssistantServiceCallAction,
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
cg.add(var.set_service("esphome.tag_scanned"))
# Initialize FixedVector with exact size (1 data field)
cg.add(var.init_data(1))
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
cg.add(var.add_data("tag_id", templ))
return var
@@ -715,7 +685,6 @@ API_RESPOND_ACTION_SCHEMA = cv.All(
"api.respond",
APIRespondAction,
API_RESPOND_ACTION_SCHEMA,
synchronous=True,
)
async def api_respond_to_code(
config: ConfigType,
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
#include "api_buffer.h"
namespace esphome::api {
void APIBuffer::grow_(size_t n) {
auto new_data = make_buffer(n);
if (this->size_)
std::memcpy(new_data.get(), this->data_.get(), this->size_);
this->data_ = std::move(new_data);
this->capacity_ = n;
}
} // namespace esphome::api
-73
View File
@@ -1,73 +0,0 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <memory>
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
namespace esphome::api {
/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
return std::make_unique<uint8_t[]>(n);
#else
return std::make_unique_for_overwrite<uint8_t[]>(n);
#endif
}
/// Byte buffer that skips zero-initialization on resize().
///
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
/// shared protobuf write buffer, every byte is overwritten by the encoder,
/// making the zero-fill pure waste. For the receive buffer, bytes are
/// overwritten by socket reads.
///
/// Designed for bulk clear/resize/overwrite patterns. grow_() allocates
/// exactly the requested size (no growth factor) since callers resize to
/// known sizes rather than appending incrementally.
///
/// Safe because: callers always write exactly the number of bytes they
/// resize for. In the protobuf write path, debug_check_bounds_ validates
/// writes in debug builds.
class APIBuffer {
public:
void clear() { this->size_ = 0; }
inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
if (n > this->capacity_)
this->grow_(n);
}
inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
this->reserve(n);
this->size_ = n; // no zero-fill
}
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
/// Single grow_ check regardless of argument order.
inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
this->reserve(std::max(reserve_size, new_size));
this->size_ = new_size;
}
uint8_t *data() { return this->data_.get(); }
const uint8_t *data() const { return this->data_.get(); }
size_t size() const { return this->size_; }
bool empty() const { return this->size_ == 0; }
uint8_t &operator[](size_t i) { return this->data_[i]; }
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
/// Release all memory (equivalent to std::vector swap trick).
void release() {
this->data_.reset();
this->size_ = 0;
this->capacity_ = 0;
}
protected:
void grow_(size_t n);
std::unique_ptr<uint8_t[]> data_;
size_t size_{0};
size_t capacity_{0};
};
} // namespace esphome::api
File diff suppressed because it is too large Load Diff
+123 -306
View File
@@ -3,26 +3,11 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
#ifdef USE_API_PLAINTEXT
#include "api_frame_helper_plaintext.h"
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
#include "esphome/components/esp32/crash_handler.h"
#endif
#ifdef USE_RP2040_CRASH_HANDLER
#include "esphome/components/rp2040/crash_handler.h"
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
#include "esphome/components/esp8266/crash_handler.h"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/string_ref.h"
@@ -47,46 +32,16 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
#ifdef USE_BENCHMARK
class APIConnection;
void bench_enable_immediate_send(APIConnection *conn);
void bench_clear_batch(APIConnection *conn);
void bench_process_batch(APIConnection *conn);
#endif
class APIConnection final : public APIServerConnectionBase {
public:
friend class APIServer;
friend class ListEntitiesIterator;
#ifdef USE_BENCHMARK
friend void bench_enable_immediate_send(APIConnection *conn);
friend void bench_clear_batch(APIConnection *conn);
friend void bench_process_batch(APIConnection *conn);
#endif
APIConnection(std::unique_ptr<socket::Socket> socket, APIServer *parent);
~APIConnection();
virtual ~APIConnection();
void start();
void loop();
protected:
// read_message_ is defined here (instead of in APIServerConnectionBase) so the
// compiler can devirtualize and inline on_* handler calls within this final class.
void read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data);
// Auth helpers defined here (not in ProtoService) so the compiler can
// devirtualize is_connection_setup()/on_no_setup_connection() calls
// within this final class.
inline bool check_connection_setup_() {
if (!this->is_connection_setup()) {
this->on_no_setup_connection();
return false;
}
return true;
}
inline bool check_authenticated_() { return this->check_connection_setup_(); }
public:
bool send_list_info_done() {
return this->schedule_message_(nullptr, ListEntitiesDoneResponse::MESSAGE_TYPE,
ListEntitiesDoneResponse::ESTIMATED_SIZE);
@@ -96,173 +51,163 @@ class APIConnection final : public APIServerConnectionBase {
#endif
#ifdef USE_COVER
bool send_cover_state(cover::Cover *cover);
void on_cover_command_request(const CoverCommandRequest &msg);
void on_cover_command_request(const CoverCommandRequest &msg) override;
#endif
#ifdef USE_FAN
bool send_fan_state(fan::Fan *fan);
void on_fan_command_request(const FanCommandRequest &msg);
void on_fan_command_request(const FanCommandRequest &msg) override;
#endif
#ifdef USE_LIGHT
bool send_light_state(light::LightState *light);
void on_light_command_request(const LightCommandRequest &msg);
void on_light_command_request(const LightCommandRequest &msg) override;
#endif
#ifdef USE_SENSOR
bool send_sensor_state(sensor::Sensor *sensor);
#endif
#ifdef USE_SWITCH
bool send_switch_state(switch_::Switch *a_switch);
void on_switch_command_request(const SwitchCommandRequest &msg);
void on_switch_command_request(const SwitchCommandRequest &msg) override;
#endif
#ifdef USE_TEXT_SENSOR
bool send_text_sensor_state(text_sensor::TextSensor *text_sensor);
#endif
#ifdef USE_CAMERA
void set_camera_state(std::shared_ptr<camera::CameraImage> image);
void on_camera_image_request(const CameraImageRequest &msg);
void on_camera_image_request(const CameraImageRequest &msg) override;
#endif
#ifdef USE_CLIMATE
bool send_climate_state(climate::Climate *climate);
void on_climate_command_request(const ClimateCommandRequest &msg);
void on_climate_command_request(const ClimateCommandRequest &msg) override;
#endif
#ifdef USE_NUMBER
bool send_number_state(number::Number *number);
void on_number_command_request(const NumberCommandRequest &msg);
void on_number_command_request(const NumberCommandRequest &msg) override;
#endif
#ifdef USE_DATETIME_DATE
bool send_date_state(datetime::DateEntity *date);
void on_date_command_request(const DateCommandRequest &msg);
void on_date_command_request(const DateCommandRequest &msg) override;
#endif
#ifdef USE_DATETIME_TIME
bool send_time_state(datetime::TimeEntity *time);
void on_time_command_request(const TimeCommandRequest &msg);
void on_time_command_request(const TimeCommandRequest &msg) override;
#endif
#ifdef USE_DATETIME_DATETIME
bool send_datetime_state(datetime::DateTimeEntity *datetime);
void on_date_time_command_request(const DateTimeCommandRequest &msg);
void on_date_time_command_request(const DateTimeCommandRequest &msg) override;
#endif
#ifdef USE_TEXT
bool send_text_state(text::Text *text);
void on_text_command_request(const TextCommandRequest &msg);
void on_text_command_request(const TextCommandRequest &msg) override;
#endif
#ifdef USE_SELECT
bool send_select_state(select::Select *select);
void on_select_command_request(const SelectCommandRequest &msg);
void on_select_command_request(const SelectCommandRequest &msg) override;
#endif
#ifdef USE_BUTTON
void on_button_command_request(const ButtonCommandRequest &msg);
void on_button_command_request(const ButtonCommandRequest &msg) override;
#endif
#ifdef USE_LOCK
bool send_lock_state(lock::Lock *a_lock);
void on_lock_command_request(const LockCommandRequest &msg);
void on_lock_command_request(const LockCommandRequest &msg) override;
#endif
#ifdef USE_VALVE
bool send_valve_state(valve::Valve *valve);
void on_valve_command_request(const ValveCommandRequest &msg);
void on_valve_command_request(const ValveCommandRequest &msg) override;
#endif
#ifdef USE_MEDIA_PLAYER
bool send_media_player_state(media_player::MediaPlayer *media_player);
void on_media_player_command_request(const MediaPlayerCommandRequest &msg);
void on_media_player_command_request(const MediaPlayerCommandRequest &msg) override;
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return;
this->send_message(call);
this->send_message(call, HomeassistantActionRequest::MESSAGE_TYPE);
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
void on_homeassistant_action_response(const HomeassistantActionResponse &msg) override;
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
#endif // USE_API_HOMEASSISTANT_SERVICES
#ifdef USE_BLUETOOTH_PROXY
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
void on_unsubscribe_bluetooth_le_advertisements_request();
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
void on_unsubscribe_bluetooth_le_advertisements_request() override;
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg);
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg);
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
void on_subscribe_bluetooth_connections_free_request();
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg) override;
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) override;
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) override;
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) override;
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) override;
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) override;
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override;
void on_subscribe_bluetooth_connections_free_request() override;
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override;
#endif
#ifdef USE_HOMEASSISTANT_TIME
void send_time_request() {
GetTimeRequest req;
this->send_message(req);
this->send_message(req, GetTimeRequest::MESSAGE_TYPE);
}
#endif
#ifdef USE_VOICE_ASSISTANT
void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg);
void on_voice_assistant_response(const VoiceAssistantResponse &msg);
void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg);
void on_voice_assistant_audio(const VoiceAssistantAudio &msg);
void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg);
void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg);
void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg);
void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg);
void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) override;
void on_voice_assistant_response(const VoiceAssistantResponse &msg) override;
void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) override;
void on_voice_assistant_audio(const VoiceAssistantAudio &msg) override;
void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg) override;
void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg) override;
void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) override;
void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override;
#endif
#ifdef USE_ZWAVE_PROXY
void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg);
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override;
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override;
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override;
#endif
#ifdef USE_WATER_HEATER
bool send_water_heater_state(water_heater::WaterHeater *water_heater);
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override;
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
#ifdef USE_IR_RF
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override;
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg);
void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
#ifdef USE_EVENT
void send_event(event::Event *event);
#endif
#ifdef USE_UPDATE
bool send_update_state(update::UpdateEntity *update);
void on_update_command_request(const UpdateCommandRequest &msg);
void on_update_command_request(const UpdateCommandRequest &msg) override;
#endif
void on_disconnect_response();
void on_ping_response() {
void on_disconnect_response() override;
void on_ping_response() override {
// we initiated ping
this->flags_.sent_ping = false;
}
#ifdef USE_API_HOMEASSISTANT_STATES
void on_home_assistant_state_response(const HomeAssistantStateResponse &msg);
void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override;
#endif
#ifdef USE_HOMEASSISTANT_TIME
void on_get_time_response(const GetTimeResponse &value);
void on_get_time_response(const GetTimeResponse &value) override;
#endif
void on_hello_request(const HelloRequest &msg);
void on_disconnect_request();
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
void on_hello_request(const HelloRequest &msg) override;
void on_disconnect_request() override;
void on_ping_request() override;
void on_device_info_request() override;
void on_list_entities_request() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() override {
this->flags_.state_subscription = true;
// Start initial state iterator only if no iterator is active
// If list_entities is running, we'll start initial_state when it completes
@@ -270,29 +215,19 @@ class APIConnection final : public APIServerConnectionBase {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#ifdef USE_ESP32_CRASH_HANDLER
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
#endif
}
#ifdef USE_API_HOMEASSISTANT_SERVICES
void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; }
void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; }
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void on_subscribe_home_assistant_states_request();
void on_subscribe_home_assistant_states_request() override;
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
void on_execute_service_request(const ExecuteServiceRequest &msg);
void on_execute_service_request(const ExecuteServiceRequest &msg) override;
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
void send_execute_service_response(uint32_t call_id, bool success, StringRef error_message);
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
@@ -302,17 +237,16 @@ class APIConnection final : public APIServerConnectionBase {
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
#ifdef USE_API_NOISE
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override;
#endif
bool is_authenticated() {
bool is_authenticated() override {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
bool is_connection_setup() {
bool is_connection_setup() override {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
}
bool is_marked_for_removal() const { return this->flags_.remove; }
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
// Get client API version for feature detection
@@ -321,47 +255,29 @@ class APIConnection final : public APIServerConnectionBase {
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
}
void on_fatal_error();
void on_no_setup_connection();
void on_fatal_error() override;
void on_no_setup_connection() override;
bool send_message_impl(const ProtoMessage &msg, uint8_t message_type) override;
// Function pointer type for type-erased message encoding
using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
template<typename T> bool send_message(const T &msg) {
if constexpr (T::ESTIMATED_SIZE == 0) {
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
} else {
return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg<T>, &msg);
}
}
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
void prepare_first_message_buffer(std::vector<uint8_t> &shared_buf, size_t header_padding, size_t total_size) {
shared_buf.clear();
// Reserve space for header padding + message + footer
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
// Reserve full size but only set initial size to header padding
// so message encoding starts at the correct position
shared_buf.reserve_and_resize(total_size, header_padding);
shared_buf.reserve(total_size);
// Resize to add header padding so message encoding starts at the correct position
shared_buf.resize(header_padding);
}
// Convenience overload - computes frame overhead internally
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
void prepare_first_message_buffer(std::vector<uint8_t> &shared_buf, size_t payload_size) {
const uint8_t header_padding = this->helper_->frame_header_padding();
const uint8_t footer_size = this->helper_->frame_footer_size();
this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
}
bool try_to_clear_buffer(bool log_out_of_space) {
if (this->flags_.remove)
return false;
if (this->helper_->can_write_without_blocking())
return true;
return this->try_to_clear_buffer_slow_(log_out_of_space);
}
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
bool try_to_clear_buffer(bool log_out_of_space);
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override;
const char *get_name() const { return this->helper_->get_client_name(); }
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
@@ -370,8 +286,6 @@ class APIConnection final : public APIServerConnectionBase {
}
protected:
bool try_to_clear_buffer_slow_(bool log_out_of_space);
// Helper function to handle authentication completion
void complete_authentication_();
@@ -398,112 +312,50 @@ class APIConnection final : public APIServerConnectionBase {
void process_state_subscriptions_();
#endif
// Size thunk — converts void* back to concrete type for direct calculate_size() call
template<typename T> static uint32_t calc_size(const void *msg) {
return static_cast<const T *>(msg)->calculate_size();
}
// Non-template helper to encode any ProtoMessage
static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn,
uint32_t remaining_size);
// Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0)
static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) {
return buf.get_pos();
}
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
// Helper to fill entity state base and encode message
static uint16_t fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, uint8_t message_type,
APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_object_id_hash();
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
return encode_message_to_buffer(msg, message_type, conn, remaining_size);
}
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
// Helper to fill entity info base and encode message
static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg, uint8_t message_type,
APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types
msg.key = entity->get_object_id_hash();
// API 1.14+ clients compute object_id client-side from the entity name
// For older clients, we must send object_id for backward compatibility
// See: https://github.com/esphome/backlog/issues/76
// TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then
// Buffer must remain in scope until encode_message_to_buffer is called
char object_id_buf[OBJECT_ID_MAX_LEN];
if (!conn->client_supports_api_version(1, 14)) {
msg.object_id = entity->get_object_id_to(object_id_buf);
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
static uint16_t encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size);
// Thin template wrapper — uses noinline encode_to_buffer_slow since
// encode_message_to_buffer callers are cold paths (zero-payload control messages).
// Hot paths (state/info) go through fill_and_encode_entity_state/info instead.
// batch_message_type_ is already set by dispatch_message_ before reaching here.
template<typename T> static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) {
if constexpr (T::ESTIMATED_SIZE == 0) {
return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size);
} else {
return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg<T>, &msg, conn, remaining_size);
if (entity->has_own_name()) {
msg.name = entity->get_name();
}
}
// Non-template core — fills state fields and encodes
static uint16_t fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn,
uint32_t remaining_size);
// Thin template wrapper
template<typename T>
static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn,
uint32_t remaining_size) {
return fill_and_encode_entity_state(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
}
// Non-template core — fills info fields, allocates buffers, and encodes
static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn,
uint32_t remaining_size);
// Thin template wrapper
template<typename T>
static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn,
uint32_t remaining_size) {
return fill_and_encode_entity_info(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
}
// Non-template core — fills device_class, then delegates to fill_and_encode_entity_info
static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg,
StringRef &device_class_field, CalculateSizeFn size_fn,
MessageEncodeFn encode_fn, APIConnection *conn,
uint32_t remaining_size);
// Thin template wrapper
template<typename T>
static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg,
StringRef &device_class_field, APIConnection *conn,
uint32_t remaining_size) {
return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &calc_size<T>,
&proto_encode_msg<T>, conn, remaining_size);
// Set common EntityBase properties
#ifdef USE_ENTITY_ICON
msg.icon = entity->get_icon_ref();
#endif
msg.disabled_by_default = entity->is_disabled_by_default();
msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category());
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
return encode_message_to_buffer(msg, message_type, conn, remaining_size);
}
#ifdef USE_VOICE_ASSISTANT
@@ -518,10 +370,6 @@ class APIConnection final : public APIServerConnectionBase {
return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
}
// Send keepalive ping or disconnect unresponsive client.
// Cold path — extracted from loop() to reduce instruction cache pressure.
void __attribute__((noinline)) check_keepalive_(uint32_t now);
// Process active iterator (list_entities/initial_state) during connection setup.
// Extracted from loop() — only runs during initial handshake, NONE in steady state.
void __attribute__((noinline)) process_active_iterator_();
@@ -612,9 +460,6 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_INFRARED
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
#endif
#ifdef USE_RADIO_FREQUENCY
static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
#endif
#ifdef USE_EVENT
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size);
@@ -640,13 +485,7 @@ class APIConnection final : public APIServerConnectionBase {
// === Optimal member ordering for 32-bit systems ===
// Group 1: Pointers (4 bytes each on 32-bit)
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
std::unique_ptr<APIFrameHelper> helper_;
#elif defined(USE_API_NOISE)
std::unique_ptr<APINoiseFrameHelper> helper_;
#elif defined(USE_API_PLAINTEXT)
std::unique_ptr<APIPlaintextFrameHelper> helper_;
#endif
APIServer *parent_;
// Group 2: Iterator union (saves ~16 bytes vs separate iterators)
@@ -665,7 +504,6 @@ class APIConnection final : public APIServerConnectionBase {
// Helper methods for iterator lifecycle management
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
#ifdef USE_CAMERA
std::unique_ptr<camera::CameraImageReader> image_reader_;
#endif
@@ -700,28 +538,11 @@ class APIConnection final : public APIServerConnectionBase {
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
uint8_t aux_data_index = AUX_DATA_UNUSED);
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
// Single push_back site to avoid duplicate _M_realloc_insert instantiation
void push_item(const BatchItem &item);
// Clear all items
void clear() {
@@ -774,7 +595,6 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t batch_scheduled : 1;
uint8_t batch_first_message : 1; // For batch buffer allocation
uint8_t should_try_send_immediately : 1; // True after initial states are sent
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
#ifdef HAS_PROTO_MESSAGE_DUMP
uint8_t log_only_mode : 1;
#endif
@@ -783,16 +603,11 @@ class APIConnection final : public APIServerConnectionBase {
// 2-byte types immediately after flags_ (no padding between them)
uint16_t client_api_version_major_{0};
uint16_t client_api_version_minor_{0};
// 1-byte types to fill remaining space before next 4-byte boundary
// 1-byte type to fill padding
ActiveIterator active_iterator_{ActiveIterator::NONE};
uint8_t batch_message_type_{0}; // Current message type during batch encoding
// Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
// Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary
// Actual header size used by encode_to_buffer for the current message.
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
uint32_t get_batch_delay_ms_() const;
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -808,8 +623,8 @@ class APIConnection final : public APIServerConnectionBase {
bool schedule_batch_();
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
void process_batch_multi_(std::vector<uint8_t> &shared_buf, size_t num_items, uint8_t header_padding,
uint8_t footer_size) __attribute__((noinline));
void clear_batch_() {
this->deferred_batch_.clear();
this->flags_.batch_scheduled = false;
@@ -859,8 +674,10 @@ class APIConnection final : public APIServerConnectionBase {
}
// Helper function to schedule a high priority message at the front of the batch
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
return this->schedule_batch_();
}
// Helper function to log client messages with name and peername
void log_client_(int level, const LogString *message);
+137 -70
View File
@@ -100,81 +100,149 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("UNKNOWN");
}
#ifdef HELPER_LOG_PACKETS
void APIFrameHelper::log_packet_sending_(const void *data, uint16_t len) {
LOG_PACKET_SENDING(reinterpret_cast<const uint8_t *>(data), len);
}
#endif
APIError APIFrameHelper::drain_overflow_and_handle_errors_() {
if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) {
int err = errno;
if (err != EWOULDBLOCK && err != EAGAIN) {
this->state_ = State::FAILED;
HELPER_LOG("Socket write failed with errno %d", err);
return APIError::SOCKET_WRITE_FAILED;
// Default implementation for loop - handles sending buffered data
APIError APIFrameHelper::loop() {
if (this->tx_buf_count_ > 0) {
APIError err = try_send_tx_buf_();
if (err != APIError::OK && err != APIError::WOULD_BLOCK) {
return err;
}
}
return APIError::OK;
return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination
}
// Single-buffer write path: wraps in iovec and delegates.
APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t sent) {
struct iovec iov = {const_cast<void *>(data), len};
APIError err = this->write_raw_iov_(&iov, 1, len, sent);
#ifdef HELPER_LOG_PACKETS
// Log after write/enqueue so re-entrant log sends can't corrupt data before it's sent
if (err == APIError::OK)
LOG_PACKET_SENDING(reinterpret_cast<const uint8_t *>(data), len);
#endif
return err;
}
// Handles partial writes, errors, and overflow buffering.
// Called when the inline fast path couldn't complete the write,
// or directly from cold paths (handshake, error handling).
APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) {
if (sent <= 0) {
if (sent == WRITE_NOT_ATTEMPTED) {
// Cold path: no write attempted yet, drain overflow and try
if (!this->overflow_buf_.empty()) {
APIError err = this->drain_overflow_and_handle_errors_();
if (err != APIError::OK)
return err;
}
if (this->overflow_buf_.empty()) {
sent = this->write_iov_to_socket_(iov, iovcnt);
if (sent == static_cast<ssize_t>(total_write_len))
return APIError::OK;
// Partial write or -1: fall through to error check / enqueue below
} else {
// Overflow backlog remains after drain; skip socket write, enqueue everything
sent = 0;
}
}
// WRITE_FAILED (-1): fast path or retry write returned -1, check errno
if (sent == WRITE_FAILED) {
int err = errno;
if (err != EWOULDBLOCK && err != EAGAIN) {
this->state_ = State::FAILED;
HELPER_LOG("Socket write failed with errno %d", err);
return APIError::SOCKET_WRITE_FAILED;
}
sent = 0; // Treat WOULD_BLOCK as zero bytes sent
}
// Common socket write error handling
APIError APIFrameHelper::handle_socket_write_error_() {
if (errno == EWOULDBLOCK || errno == EAGAIN) {
return APIError::WOULD_BLOCK;
}
HELPER_LOG("Socket write failed with errno %d", errno);
this->state_ = State::FAILED;
return APIError::SOCKET_WRITE_FAILED;
}
// Full write completed (possible when called directly, not via write_raw_fast_buf_)
if (sent == static_cast<ssize_t>(total_write_len))
return APIError::OK;
// Queue unsent data into overflow buffer
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
HELPER_LOG("Overflow buffer full, dropping connection");
// Helper method to buffer data from IOVs
void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len,
uint16_t offset) {
// Check if queue is full
if (this->tx_buf_count_ >= API_MAX_SEND_QUEUE) {
HELPER_LOG("Send queue full (%u buffers), dropping connection", this->tx_buf_count_);
this->state_ = State::FAILED;
return APIError::SOCKET_WRITE_FAILED;
return;
}
return APIError::OK;
uint16_t buffer_size = total_write_len - offset;
auto &buffer = this->tx_buf_[this->tx_buf_tail_];
buffer = std::make_unique<SendBuffer>(SendBuffer{
.data = std::make_unique<uint8_t[]>(buffer_size),
.size = buffer_size,
.offset = 0,
});
uint16_t to_skip = offset;
uint16_t write_pos = 0;
for (int i = 0; i < iovcnt; i++) {
if (to_skip >= iov[i].iov_len) {
// Skip this entire segment
to_skip -= static_cast<uint16_t>(iov[i].iov_len);
} else {
// Include this segment (partially or fully)
const uint8_t *src = reinterpret_cast<uint8_t *>(iov[i].iov_base) + to_skip;
uint16_t len = static_cast<uint16_t>(iov[i].iov_len) - to_skip;
std::memcpy(buffer->data.get() + write_pos, src, len);
write_pos += len;
to_skip = 0;
}
}
// Update circular buffer tracking
this->tx_buf_tail_ = (this->tx_buf_tail_ + 1) % API_MAX_SEND_QUEUE;
this->tx_buf_count_++;
}
// This method writes data to socket or buffers it
APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) {
// Returns APIError::OK if successful (or would block, but data has been buffered)
// Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED
if (iovcnt == 0)
return APIError::OK; // Nothing to do, success
#ifdef HELPER_LOG_PACKETS
for (int i = 0; i < iovcnt; i++) {
LOG_PACKET_SENDING(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
}
#endif
// Try to send any existing buffered data first if there is any
if (this->tx_buf_count_ > 0) {
APIError send_result = try_send_tx_buf_();
// If real error occurred (not just WOULD_BLOCK), return it
if (send_result != APIError::OK && send_result != APIError::WOULD_BLOCK) {
return send_result;
}
// If there is still data in the buffer, we can't send, buffer
// the new data and return
if (this->tx_buf_count_ > 0) {
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0);
return APIError::OK; // Success, data buffered
}
}
// Try to send directly if no buffered data
// Optimize for single iovec case (common for plaintext API)
ssize_t sent =
(iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt);
if (sent == -1) {
APIError err = this->handle_socket_write_error_();
if (err == APIError::WOULD_BLOCK) {
// Socket would block, buffer the data
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0);
return APIError::OK; // Success, data buffered
}
return err; // Socket write failed
} else if (static_cast<uint16_t>(sent) < total_write_len) {
// Partially sent, buffer the remaining data
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent));
}
return APIError::OK; // Success, all data sent or buffered
}
// Common implementation for trying to send buffered data
// IMPORTANT: Caller MUST ensure tx_buf_count_ > 0 before calling this method
APIError APIFrameHelper::try_send_tx_buf_() {
// Try to send from tx_buf - we assume it's not empty as it's the caller's responsibility to check
while (this->tx_buf_count_ > 0) {
// Get the first buffer in the queue
SendBuffer *front_buffer = this->tx_buf_[this->tx_buf_head_].get();
// Try to send the remaining data in this buffer
ssize_t sent = this->socket_->write(front_buffer->current_data(), front_buffer->remaining());
if (sent == -1) {
return this->handle_socket_write_error_();
} else if (sent == 0) {
// Nothing sent but not an error
return APIError::WOULD_BLOCK;
} else if (static_cast<uint16_t>(sent) < front_buffer->remaining()) {
// Partially sent, update offset
// Cast to ensure no overflow issues with uint16_t
front_buffer->offset += static_cast<uint16_t>(sent);
return APIError::WOULD_BLOCK; // Stop processing more buffers if we couldn't send a complete buffer
} else {
// Buffer completely sent, remove it from the queue
this->tx_buf_[this->tx_buf_head_].reset();
this->tx_buf_head_ = (this->tx_buf_head_ + 1) % API_MAX_SEND_QUEUE;
this->tx_buf_count_--;
// Continue loop to try sending the next buffer
}
}
return APIError::OK; // All buffers sent successfully
}
const char *APIFrameHelper::get_peername_to(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
@@ -210,12 +278,11 @@ APIError APIFrameHelper::init_common_() {
APIError APIFrameHelper::handle_socket_read_result_(ssize_t received) {
if (received == -1) {
const int err = errno;
if (err == EWOULDBLOCK || err == EAGAIN) {
if (errno == EWOULDBLOCK || errno == EAGAIN) {
return APIError::WOULD_BLOCK;
}
state_ = State::FAILED;
HELPER_LOG("Socket read failed with errno %d", err);
HELPER_LOG("Socket read failed with errno %d", errno);
return APIError::SOCKET_READ_FAILED;
} else if (received == 0) {
state_ = State::FAILED;
+62 -121
View File
@@ -5,15 +5,13 @@
#include <memory>
#include <span>
#include <utility>
#include <vector>
#include "esphome/core/defines.h"
#ifdef USE_API
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/api_overflow_buffer.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "proto.h"
namespace esphome::api {
@@ -31,14 +29,12 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 8192; // 8 KiB for ESP8266
static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and other platforms
#endif
// Extra byte reserved in rx_buf_ beyond the message size so protobuf
// StringRef fields can be null-terminated in-place after decode.
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
class ProtoWriteBuffer;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
static constexpr size_t CLIENT_INFO_NAME_MAX_LEN = 32;
@@ -49,17 +45,12 @@ struct ReadPacketBuffer {
};
// Packed message info structure to minimize memory usage
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
struct MessageInfo {
uint16_t offset; // Offset in buffer where message starts
uint16_t payload_size; // Size of the message payload
uint8_t message_type; // Message type (0-255)
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
MessageInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {}
};
enum class APIError : uint16_t {
@@ -110,9 +101,9 @@ class APIFrameHelper {
}
virtual ~APIFrameHelper() = default;
virtual APIError init() = 0;
virtual APIError loop() = 0;
virtual APIError loop();
virtual APIError read_packet(ReadPacketBuffer *buffer) = 0;
bool can_write_without_blocking() { return this->state_ == State::DATA && this->overflow_buf_.empty(); }
bool can_write_without_blocking() { return this->state_ == State::DATA && this->tx_buf_count_ == 0; }
int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); }
APIError close() {
if (state_ == State::CLOSED)
@@ -139,66 +130,43 @@ class APIFrameHelper {
//
// For log messages: Use Nagle to coalesce multiple small log packets into
// fewer larger packets, reducing WiFi overhead. However, we limit batching
// to avoid excessive LWIP buffer pressure on memory-constrained devices.
// LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
// to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained
// devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into
// shared pbufs, but holding data too long waiting for Nagle's timer causes
// buffer exhaustion and dropped messages.
//
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
// Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all)
// Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all)
//
void set_nodelay_for_message(bool is_log_message) {
if (!is_log_message) {
if (this->nodelay_counter_) {
if (this->nodelay_state_ != NODELAY_ON) {
this->set_nodelay_raw_(true);
this->nodelay_counter_ = 0;
this->nodelay_state_ = NODELAY_ON;
}
return;
}
// Log message: enable Nagle on first, flush after LOG_NAGLE_COUNT
if (!this->nodelay_counter_)
// Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd)
if (this->nodelay_state_ == NODELAY_ON) {
this->set_nodelay_raw_(false);
if (++this->nodelay_counter_ > LOG_NAGLE_COUNT) {
this->nodelay_state_ = 1;
} else if (this->nodelay_state_ >= LOG_NAGLE_COUNT) {
this->set_nodelay_raw_(true);
this->nodelay_counter_ = 0;
this->nodelay_state_ = NODELAY_ON;
} else {
this->nodelay_state_++;
}
}
// Write a single protobuf message - the hot path (87-100% of all writes).
// Caller must ensure state is DATA before calling.
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
// Write multiple protobuf messages in a single batched operation.
// Caller must ensure state is DATA and messages is not empty.
// messages contains (message_type, offset, length) for each message in the buffer.
// The buffer contains all messages with appropriate padding before each.
// Write multiple protobuf messages in a single operation
// messages contains (message_type, offset, length) for each message in the buffer
// The buffer contains all messages with appropriate padding before each
virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) = 0;
// Get the maximum frame header padding required by this protocol (worst case)
// Get the frame header padding required by this protocol
uint8_t frame_header_padding() const { return frame_header_padding_; }
// Get the actual frame header size for a specific message.
// For noise: always returns frame_header_padding_ (fixed 7-byte header).
// For plaintext: computes actual size from varint lengths (3-6 bytes).
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
// added, this should become a virtual method.
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
return this->frame_footer_size_
? this->frame_header_padding_
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
#elif defined(USE_API_NOISE)
return this->frame_header_padding_;
#else // USE_API_PLAINTEXT only
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
#endif
}
// Get the frame footer size required by this protocol
uint8_t frame_footer_size() const { return frame_footer_size_; }
// Check if socket has buffered data ready to read.
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
// Check if socket has data ready to read
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
// Release excess memory from internal buffers after initial sync
void release_buffers() {
@@ -206,51 +174,37 @@ class APIFrameHelper {
// rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame
// and clearing would lose partially received data.
if (this->rx_buf_len_ == 0) {
this->rx_buf_.release();
// Use swap trick since shrink_to_fit() is non-binding and may be ignored
std::vector<uint8_t>().swap(this->rx_buf_);
}
}
protected:
// Drain backlogged overflow data to the socket and handle errors.
// Called when overflow_buf_.empty() is false. Out-of-line to keep the
// fast path (empty check) inline at call sites.
// Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors.
APIError drain_overflow_and_handle_errors_();
// Buffer containing data to be sent
struct SendBuffer {
std::unique_ptr<uint8_t[]> data;
uint16_t size{0}; // Total size of the buffer
uint16_t offset{0}; // Current offset within the buffer
// Sentinel values for the sent parameter in write_raw_ methods
static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1
static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet
// Using uint16_t reduces memory usage since ESPHome API messages are limited to UINT16_MAX (65535) bytes
uint16_t remaining() const { return size - offset; }
const uint8_t *current_data() const { return data.get() + offset; }
};
// Dispatch to write() or writev() based on iovec count
inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) {
return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt);
}
// Common implementation for writing raw data to socket
APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len);
// Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages)
// These inline the fast path (overflow empty + full write) and tail-call the out-of-line
// slow path only on failure/partial write.
inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) {
if (this->overflow_buf_.empty()) [[likely]] {
ssize_t sent = this->socket_->write(data, len);
if (sent == static_cast<ssize_t>(len)) [[likely]] {
#ifdef HELPER_LOG_PACKETS
this->log_packet_sending_(data, len);
#endif
return APIError::OK;
}
// sent is -1 (WRITE_FAILED) or partial write count
return this->write_raw_buf_(data, len, sent);
}
return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED);
}
// Out-of-line write paths: handle partial writes, errors, overflow buffering
// sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write)
APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED);
APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len,
ssize_t sent = WRITE_NOT_ATTEMPTED);
#ifdef HELPER_LOG_PACKETS
void log_packet_sending_(const void *data, uint16_t len);
#endif
// Try to send data from the tx buffer
APIError try_send_tx_buf_();
// Helper method to buffer data from IOVs
void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t offset);
// Common socket write error handling
APIError handle_socket_write_error_();
template<typename StateEnum>
APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector<uint8_t> &tx_buf,
const std::string &info, StateEnum &state, StateEnum failed_state);
// Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit)
std::unique_ptr<socket::Socket> socket_;
@@ -274,20 +228,9 @@ class APIFrameHelper {
EXPLICIT_REJECT = 8, // Noise only
};
// Fast inline state check for read_packet/write_protobuf_messages hot path.
// Returns OK only in DATA state; maps CLOSED/FAILED to BAD_STATE and any
// other intermediate state to WOULD_BLOCK.
inline APIError ESPHOME_ALWAYS_INLINE check_data_state_() const {
if (this->state_ == State::DATA)
return APIError::OK;
if (this->state_ == State::CLOSED || this->state_ == State::FAILED)
return APIError::BAD_STATE;
return APIError::WOULD_BLOCK;
}
// Backlog for unsent data when TCP send buffer is full (rarely used in production)
APIOverflowBuffer overflow_buf_;
APIBuffer rx_buf_;
// Containers (size varies, but typically 12+ bytes on 32-bit)
std::array<std::unique_ptr<SendBuffer>, API_MAX_SEND_QUEUE> tx_buf_;
std::vector<uint8_t> rx_buf_;
// Client name buffer - stores name from Hello message or initial peername
char client_name_[CLIENT_INFO_NAME_MAX_LEN]{};
@@ -297,17 +240,15 @@ class APIFrameHelper {
State state_{State::INITIALIZE};
uint8_t frame_header_padding_{0};
uint8_t frame_footer_size_{0};
// Nagle batching counter for log messages. 0 means NODELAY is enabled (immediate send).
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
static constexpr uint8_t LOG_NAGLE_COUNT = 3;
#endif
uint8_t nodelay_counter_{0};
uint8_t tx_buf_head_{0};
uint8_t tx_buf_tail_{0};
uint8_t tx_buf_count_{0};
// Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled
// (immediate send). Values 1-2 count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset.
static constexpr int8_t NODELAY_ON = -1;
static constexpr int8_t LOG_NAGLE_COUNT = 2;
int8_t nodelay_state_{NODELAY_ON};
// Internal helper to set TCP_NODELAY socket option
void set_nodelay_raw_(bool enable) {
+214 -241
View File
@@ -47,8 +47,15 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
format_hex_pretty_to(hex_buf_, (buffer).data(), \
(buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \
} while (0)
#define LOG_PACKET_SENDING(data, len) \
do { \
char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \
ESP_LOGVV(TAG, "Sending raw: %s", \
format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \
} while (0)
#else
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#define LOG_PACKET_SENDING(data, len) ((void) 0)
#endif
/// Convert a noise error code to a readable error
@@ -146,10 +153,8 @@ APIError APINoiseFrameHelper::loop() {
}
}
if (!this->overflow_buf_.empty()) [[unlikely]] {
return this->drain_overflow_and_handle_errors_();
}
return APIError::OK;
// Use base class implementation for buffer sending
return APIFrameHelper::loop();
}
/** Read a packet into the rx_buf_.
@@ -189,20 +194,17 @@ APIError APINoiseFrameHelper::try_read_frame_() {
uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2];
// Check against size limits to prevent OOM: MAX_HANDSHAKE_SIZE for handshake, MAX_MESSAGE_SIZE for data
bool is_data = (state_ == State::DATA);
uint16_t limit = is_data ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
uint16_t limit = (state_ == State::DATA) ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
if (msg_size > limit) {
state_ = State::FAILED;
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, limit);
return is_data ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
}
// Reserve space for body (+ null terminator in DATA state so protobuf
// StringRef fields can be safely null-terminated in-place after decode.
// During handshake, rx_buf_.size() is used in prologue construction, so
// the buffer must be exactly msg_size to avoid prologue mismatch.)
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
this->rx_buf_.resize(alloc_size);
// Reserve space for body
if (this->rx_buf_.size() != msg_size) {
this->rx_buf_.resize(msg_size);
}
if (rx_buf_len_ < msg_size) {
// more data to read
@@ -237,144 +239,129 @@ APIError APINoiseFrameHelper::try_read_frame_() {
* If an error occurred, returns that error. Only returns OK if the transport is ready for data
* traffic.
*/
// Split into per-state methods so the compiler doesn't allocate stack space
// for all branches simultaneously. On RP2040 the core0 stack lives in a 4KB
// scratch RAM bank; the Noise crypto path (curve25519) needs ~2KB+ of stack,
// so every byte saved in the caller matters.
APIError APINoiseFrameHelper::state_action_() {
switch (this->state_) {
case State::INITIALIZE:
HELPER_LOG("Bad state for method: %d", (int) this->state_);
return APIError::BAD_STATE;
case State::CLIENT_HELLO:
return this->state_action_client_hello_();
case State::SERVER_HELLO:
return this->state_action_server_hello_();
case State::HANDSHAKE:
return this->state_action_handshake_();
case State::CLOSED:
case State::FAILED:
return APIError::BAD_STATE;
default:
return APIError::OK;
}
}
APIError APINoiseFrameHelper::state_action_client_hello_() {
// waiting for client hello
APIError aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
return handle_handshake_frame_error_(aerr);
}
// ignore contents, may be used in future for flags
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
size_t rx_size = this->rx_buf_.size();
this->prologue_.resize(old_size + 2 + rx_size);
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
this->prologue_[old_size + 1] = (uint8_t) rx_size;
if (rx_size > 0) {
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
int err;
APIError aerr;
if (state_ == State::INITIALIZE) {
HELPER_LOG("Bad state for method: %d", (int) state_);
return APIError::BAD_STATE;
}
if (state_ == State::CLIENT_HELLO) {
// waiting for client hello
aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
return handle_handshake_frame_error_(aerr);
}
// ignore contents, may be used in future for flags
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
this->prologue_.resize(old_size + 2 + this->rx_buf_.size());
this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8);
this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size();
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size());
state_ = State::SERVER_HELLO;
state_ = State::SERVER_HELLO;
}
if (state_ == State::SERVER_HELLO) {
// send server hello
const std::string &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
// Calculate positions and sizes
size_t name_len = name.size() + 1; // including null terminator
size_t name_offset = 1;
size_t mac_offset = name_offset + name_len;
size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE;
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
uint8_t msg[max_msg_size];
// chosen proto
msg[0] = 0x01;
// node name, terminated by null byte
std::memcpy(msg + name_offset, name.c_str(), name_len);
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
aerr = write_frame_(msg, total_size);
if (aerr != APIError::OK)
return aerr;
// start handshake
aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
}
if (state_ == State::HANDSHAKE) {
int action = noise_handshakestate_get_action(handshake_);
if (action == NOISE_ACTION_READ_MESSAGE) {
// waiting for handshake msg
aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
return handle_handshake_frame_error_(aerr);
}
if (this->rx_buf_.empty()) {
send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
} else if (this->rx_buf_[0] != 0x00) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr);
if (err != 0) {
// Special handling for MAC failure
send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
: LOG_STR("Handshake error"));
return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
APIError::HANDSHAKESTATE_READ_FAILED);
}
aerr = check_handshake_finished_();
if (aerr != APIError::OK)
return aerr;
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
uint8_t buffer[65];
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr);
APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
APIError::HANDSHAKESTATE_WRITE_FAILED);
if (aerr_write != APIError::OK)
return aerr_write;
buffer[0] = 0x00; // success
aerr = write_frame_(buffer, mbuf.size + 1);
if (aerr != APIError::OK)
return aerr;
aerr = check_handshake_finished_();
if (aerr != APIError::OK)
return aerr;
} else {
// bad state for action
state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
}
if (state_ == State::CLOSED || state_ == State::FAILED) {
return APIError::BAD_STATE;
}
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
// send server hello
const auto &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
// Calculate positions and sizes
size_t name_len = name.size() + 1; // including null terminator
size_t name_offset = 1;
size_t mac_offset = name_offset + name_len;
size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE;
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
uint8_t msg[max_msg_size];
// chosen proto
msg[0] = 0x01;
// node name, terminated by null byte
std::memcpy(msg + name_offset, name.c_str(), name_len);
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
APIError aerr = write_frame_(msg, total_size);
if (aerr != APIError::OK)
return aerr;
// start handshake
aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
int action = noise_handshakestate_get_action(this->handshake_);
if (action == NOISE_ACTION_READ_MESSAGE) {
return this->state_action_handshake_read_();
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
return this->state_action_handshake_write_();
}
// bad state for action
this->state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
APIError APINoiseFrameHelper::state_action_handshake_read_() {
APIError aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
return this->handle_handshake_frame_error_(aerr);
}
if (this->rx_buf_.empty()) {
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
} else if (this->rx_buf_[0] != 0x00) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
if (err != 0) {
// Special handling for MAC failure
this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
: LOG_STR("Handshake error"));
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
APIError::HANDSHAKESTATE_READ_FAILED);
}
return this->check_handshake_finished_();
}
APIError APINoiseFrameHelper::state_action_handshake_write_() {
uint8_t buffer[65];
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
APIError::HANDSHAKESTATE_WRITE_FAILED);
if (aerr != APIError::OK)
return aerr;
buffer[0] = 0x00; // success
aerr = this->write_frame_(buffer, mbuf.size + 1);
if (aerr != APIError::OK)
return aerr;
return this->check_handshake_finished_();
}
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
uint8_t data[32];
@@ -383,7 +370,6 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
#ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
}
@@ -391,7 +377,6 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
// Normal memory access
const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str);
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
std::memcpy(data + 1, reason_str, reason_len);
@@ -407,9 +392,14 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
state_ = orig_state;
}
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
APIError aerr = this->check_data_state_();
if (aerr != APIError::OK)
APIError aerr = this->state_action_();
if (aerr != APIError::OK) {
return aerr;
}
if (this->state_ != State::DATA) {
return APIError::WOULD_BLOCK;
}
aerr = this->try_read_frame_();
if (aerr != APIError::OK)
@@ -417,18 +407,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
// read_packet() must only be called in DATA state; the extra
// RX_BUF_NULL_TERMINATOR byte is only allocated in DATA state
// (see try_read_frame_), so calling this during handshake would
// underflow the size calculation below.
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
// rx_buf_ has RX_BUF_NULL_TERMINATOR extra byte for null termination
// (only added in DATA state — see try_read_frame_), so subtract it
// to get the actual encrypted data size for decryption.
size_t encrypted_size = this->rx_buf_.size() - RX_BUF_NULL_TERMINATOR;
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), encrypted_size, encrypted_size);
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), this->rx_buf_.size(), this->rx_buf_.size());
int err = noise_cipherstate_decrypt(this->recv_cipher_, &mbuf);
APIError decrypt_err =
handle_noise_error_(err, LOG_STR("noise_cipherstate_decrypt"), APIError::CIPHERSTATE_DECRYPT_FAILED);
@@ -457,83 +436,78 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
buffer->type = type;
return APIError::OK;
}
// Encrypt a single noise message in place and return the encrypted frame length.
// Returns APIError::OK on success.
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
uint16_t &encrypted_len_out) {
// Write noise header
buf_start[0] = 0x01; // indicator
// buf_start[1], buf_start[2] to be set after encryption
// Write message header (to be encrypted)
constexpr uint8_t msg_offset = 3;
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
buf_start[msg_offset + 3] = static_cast<uint8_t>(payload_size); // data_len low byte
// payload data is already in the buffer starting at offset + 7
// Encrypt the message in place
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + payload_size, 4 + payload_size + this->frame_footer_size_);
int err = noise_cipherstate_encrypt(this->send_cipher_, &mbuf);
APIError aerr =
this->handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED);
if (aerr != APIError::OK)
return aerr;
// Fill in the encrypted size
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
buf_start[2] = static_cast<uint8_t>(mbuf.size);
encrypted_len_out = static_cast<uint16_t>(3 + mbuf.size); // indicator + size + encrypted data
return APIError::OK;
}
APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
// Resize buffer to include footer space for Noise MAC
if (this->frame_footer_size_)
buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
uint16_t payload_size =
static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
uint8_t *buf_start = buffer.get_buffer()->data();
uint16_t encrypted_len;
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
if (aerr != APIError::OK)
return aerr;
return this->write_raw_fast_buf_(buf_start, encrypted_len);
// Resize to include MAC space (required for Noise encryption)
buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_);
MessageInfo msg{type, 0,
static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)};
return write_protobuf_messages(buffer, std::span<const MessageInfo>(&msg, 1));
}
APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
assert(!messages.empty());
#endif
// Noise messages are already contiguous in the buffer:
// HEADER_PADDING (7) exactly matches the fixed header size, and
// footer space (16) is consumed by the encryption MAC.
uint8_t *buffer_data = buffer.get_buffer()->data();
uint8_t *write_start = buffer_data + messages[0].offset;
uint16_t total_write_len = 0;
for (const auto &msg : messages) {
uint8_t *buf_start = buffer_data + msg.offset;
uint16_t encrypted_len;
APIError aerr = this->encrypt_noise_message_(buf_start, msg.payload_size, msg.message_type, encrypted_len);
if (aerr != APIError::OK)
return aerr;
total_write_len += encrypted_len;
APIError aerr = state_action_();
if (aerr != APIError::OK) {
return aerr;
}
return this->write_raw_fast_buf_(write_start, total_write_len);
if (state_ != State::DATA) {
return APIError::WOULD_BLOCK;
}
if (messages.empty()) {
return APIError::OK;
}
uint8_t *buffer_data = buffer.get_buffer()->data();
// Stack-allocated iovec array - no heap allocation
StaticVector<struct iovec, MAX_MESSAGES_PER_BATCH> iovs;
uint16_t total_write_len = 0;
// We need to encrypt each message in place
for (const auto &msg : messages) {
// The buffer already has padding at offset
uint8_t *buf_start = buffer_data + msg.offset;
// Write noise header
buf_start[0] = 0x01; // indicator
// buf_start[1], buf_start[2] to be set after encryption
// Write message header (to be encrypted)
constexpr uint8_t msg_offset = 3;
buf_start[msg_offset] = static_cast<uint8_t>(msg.message_type >> 8); // type high byte
buf_start[msg_offset + 1] = static_cast<uint8_t>(msg.message_type); // type low byte
buf_start[msg_offset + 2] = static_cast<uint8_t>(msg.payload_size >> 8); // data_len high byte
buf_start[msg_offset + 3] = static_cast<uint8_t>(msg.payload_size); // data_len low byte
// payload data is already in the buffer starting at offset + 7
// Make sure we have space for MAC
// The buffer should already have been sized appropriately
// Encrypt the message in place
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size,
4 + msg.payload_size + frame_footer_size_);
int err = noise_cipherstate_encrypt(send_cipher_, &mbuf);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED);
if (aerr != APIError::OK)
return aerr;
// Fill in the encrypted size
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
buf_start[2] = static_cast<uint8_t>(mbuf.size);
// Add iovec for this encrypted message
size_t msg_len = static_cast<size_t>(3 + mbuf.size); // indicator + size + encrypted data
iovs.push_back({buf_start, msg_len});
total_write_len += msg_len;
}
// Send all encrypted messages in one writev call
return this->write_raw_(iovs.data(), iovs.size(), total_write_len);
}
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
@@ -542,16 +516,16 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
header[1] = (uint8_t) (len >> 8);
header[2] = (uint8_t) len;
if (len == 0) {
return this->write_raw_buf_(header, 3);
}
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = 3;
if (len == 0) {
return this->write_raw_(iov, 1, 3); // Just header
}
iov[1].iov_base = const_cast<uint8_t *>(data);
iov[1].iov_len = len;
return this->write_raw_iov_(iov, 2, 3 + len);
return this->write_raw_(iov, 2, 3 + len); // Header + data
}
/** Initiate the data structures for the handshake.
@@ -589,7 +563,8 @@ APIError APINoiseFrameHelper::init_handshake_() {
if (aerr != APIError::OK)
return aerr;
// set_prologue copies it into handshakestate, so we can get rid of it now
prologue_.release();
// Use swap idiom to actually release memory (= {} only clears size, not capacity)
std::vector<uint8_t>().swap(prologue_);
err = noise_handshakestate_start(handshake_);
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
@@ -599,9 +574,7 @@ APIError APINoiseFrameHelper::init_handshake_() {
}
APIError APINoiseFrameHelper::check_handshake_finished_() {
#ifdef ESPHOME_DEBUG_API
assert(state_ == State::HANDSHAKE);
#endif
int action = noise_handshakestate_get_action(handshake_);
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
@@ -617,7 +590,7 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
if (aerr != APIError::OK)
return aerr;
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
HELPER_LOG("Handshake complete!");
noise_handshakestate_free(handshake_);
@@ -9,16 +9,14 @@ namespace esphome::api {
class APINoiseFrameHelper final : public APIFrameHelper {
public:
// Noise header structure:
// Pos 0: indicator (0x01)
// Pos 1-2: encrypted payload size (16-bit big-endian)
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
// Pos 7+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, APINoiseContext &ctx)
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
frame_header_padding_ = HEADER_PADDING;
// Noise header structure:
// Pos 0: indicator (0x01)
// Pos 1-2: encrypted payload size (16-bit big-endian)
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
// Pos 7+: actual payload data
frame_header_padding_ = 7;
}
~APINoiseFrameHelper() override;
APIError init() override;
@@ -29,15 +27,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
protected:
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
APIError state_action_handshake_();
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
APIError try_read_frame_();
APIError write_frame_(const uint8_t *data, uint16_t len);
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
uint16_t &encrypted_len_out);
APIError init_handshake_();
APIError check_handshake_finished_();
void send_explicit_handshake_reject_(const LogString *reason);
@@ -52,8 +43,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Reference to noise context (4 bytes on 32-bit)
APINoiseContext &ctx_;
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
// Vector (12 bytes on 32-bit)
std::vector<uint8_t> prologue_;
// NoiseProtocolId (size depends on implementation)
NoiseProtocolId nid_;
@@ -39,8 +39,15 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
format_hex_pretty_to(hex_buf_, (buffer).data(), \
(buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \
} while (0)
#define LOG_PACKET_SENDING(data, len) \
do { \
char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \
ESP_LOGVV(TAG, "Sending raw: %s", \
format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \
} while (0)
#else
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#define LOG_PACKET_SENDING(data, len) ((void) 0)
#endif
/// Initialize the frame helper, returns OK if successful.
@@ -57,10 +64,8 @@ APIError APIPlaintextFrameHelper::loop() {
if (state_ != State::DATA) {
return APIError::BAD_STATE;
}
if (!this->overflow_buf_.empty()) [[unlikely]] {
return this->drain_overflow_and_handle_errors_();
}
return APIError::OK;
// Use base class implementation for buffer sending
return APIFrameHelper::loop();
}
/** Read a packet into the rx_buf_.
@@ -123,44 +128,45 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// Skip indicator byte at position 0
uint8_t varint_pos = 1;
uint32_t consumed = 0;
// rx_header_buf_pos_ >= 3 and varint_pos == 1, so len >= 2
auto msg_size_varint = ProtoVarInt::parse_non_empty(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos);
auto msg_size_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed);
if (!msg_size_varint.has_value()) {
// not enough data there yet
continue;
}
if (msg_size_varint.value > MAX_MESSAGE_SIZE) {
if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) {
state_ = State::FAILED;
HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u",
static_cast<uint32_t>(msg_size_varint.value), MAX_MESSAGE_SIZE);
HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(),
MAX_MESSAGE_SIZE);
return APIError::BAD_DATA_PACKET;
}
rx_header_parsed_len_ = static_cast<uint16_t>(msg_size_varint.value);
rx_header_parsed_len_ = msg_size_varint->as_uint16();
// Move to next varint position
varint_pos += msg_size_varint.consumed;
varint_pos += consumed;
auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos);
auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed);
if (!msg_type_varint.has_value()) {
// not enough data there yet
continue;
}
if (msg_type_varint.value > std::numeric_limits<uint16_t>::max()) {
if (msg_type_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) {
state_ = State::FAILED;
HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u",
static_cast<uint32_t>(msg_type_varint.value), std::numeric_limits<uint16_t>::max());
HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", msg_type_varint->as_uint32(),
std::numeric_limits<uint16_t>::max());
return APIError::BAD_DATA_PACKET;
}
rx_header_parsed_type_ = static_cast<uint16_t>(msg_type_varint.value);
rx_header_parsed_type_ = msg_type_varint->as_uint16();
rx_header_parsed_ = true;
}
// header reading done
// Reserve space for body (+ null terminator so protobuf StringRef fields
// can be safely null-terminated in-place after decode)
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
// Reserve space for body
if (this->rx_buf_.size() != this->rx_header_parsed_len_) {
this->rx_buf_.resize(this->rx_header_parsed_len_);
}
if (rx_buf_len_ < rx_header_parsed_len_) {
// more data to read
@@ -188,16 +194,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
}
APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
APIError aerr = this->check_data_state_();
if (aerr != APIError::OK)
return aerr;
if (this->state_ != State::DATA) {
return APIError::WOULD_BLOCK;
}
aerr = this->try_read_frame_();
APIError aerr = this->try_read_frame_();
if (aerr != APIError::OK) {
if (aerr == APIError::BAD_INDICATOR) {
// Make sure to tell the remote that we don't
// understand the indicator byte so it knows
// we do not support it.
struct iovec iov[1];
// The \x00 first byte is the marker for plaintext.
//
// The remote will know how to handle the indicator byte,
@@ -212,12 +219,14 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
"Bad indicator byte";
char msg[INDICATOR_MSG_SIZE];
memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE);
this->write_raw_buf_(msg, INDICATOR_MSG_SIZE);
iov[0].iov_base = (void *) msg;
#else
static const char MSG[] = "\x00"
"Bad indicator byte";
this->write_raw_buf_(MSG, INDICATOR_MSG_SIZE);
iov[0].iov_base = (void *) MSG;
#endif
iov[0].iov_len = INDICATOR_MSG_SIZE;
this->write_raw_(iov, 1, INDICATOR_MSG_SIZE);
}
return aerr;
}
@@ -227,101 +236,76 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
buffer->type = this->rx_header_parsed_type_;
return APIError::OK;
}
// Encode a 16-bit varint (1-3 bytes) using pre-computed length.
ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_t varint_len, uint8_t *p) {
if (varint_len >= 2) {
*p++ = static_cast<uint8_t>(value | 0x80);
value >>= 7;
if (varint_len == 3) {
*p++ = static_cast<uint8_t>(value | 0x80);
value >>= 7;
}
}
*p = static_cast<uint8_t>(value);
}
// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
if (varint_len == 2) {
*p++ = static_cast<uint8_t>(value | 0x80);
*p = static_cast<uint8_t>(value >> 7);
} else {
*p = value;
}
}
// Write plaintext header into pre-allocated padding before payload.
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
// actual header size for contiguous batch messages).
// Returns the total header length (indicator + varints).
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
uint8_t message_type, uint8_t padding_size) {
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
uint8_t type_varint_len = ProtoSize::varint8(message_type);
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
// The header is right-justified within the padding so it sits immediately before payload.
//
// Single/first message (padding_size = HEADER_PADDING = 6):
// Example (small, header=3): [0-2] unused | [3] 0x00 | [4] size | [5] type | [6...] payload
// Example (medium, header=4): [0-1] unused | [2] 0x00 | [3-4] size | [5] type | [6...] payload
// Example (large, header=6): [0] 0x00 | [1-3] size | [4-5] type | [6...] payload
//
// Batch messages 2+ (padding_size = actual header size, no unused bytes):
// Example (small, header=3): [0] 0x00 | [1] size | [2] type | [3...] payload
// Example (medium, header=4): [0] 0x00 | [1-2] size | [3] type | [4...] payload
#ifdef ESPHOME_DEBUG_API
assert(padding_size >= total_header_len);
#endif
uint32_t header_offset = padding_size - total_header_len;
// Write the plaintext header
buf_start[header_offset] = 0x00; // indicator
// Encode varints directly into buffer using pre-computed lengths
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
return total_header_len;
}
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
uint16_t payload_size = static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING);
uint8_t *buffer_data = buffer.get_buffer()->data();
uint8_t header_len = write_plaintext_header(buffer_data, payload_size, type, HEADER_PADDING);
return this->write_raw_fast_buf_(buffer_data + HEADER_PADDING - header_len,
static_cast<uint16_t>(header_len + payload_size));
MessageInfo msg{type, 0, static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_)};
return write_protobuf_messages(buffer, std::span<const MessageInfo>(&msg, 1));
}
APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer,
std::span<const MessageInfo> messages) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
assert(!messages.empty());
#endif
uint8_t *buffer_data = buffer.get_buffer()->data();
// First message has max padding (header_size = HEADER_PADDING), may have unused leading bytes.
// Subsequent messages were encoded with exact header sizes (header_size = actual header len).
// write_plaintext_header right-justifies the header within header_size bytes of padding.
const auto &first = messages[0];
uint8_t *first_start = buffer_data + first.offset;
uint8_t header_len = write_plaintext_header(first_start, first.payload_size, first.message_type, HEADER_PADDING);
uint8_t *write_start = first_start + HEADER_PADDING - header_len;
uint16_t total_len = header_len + first.payload_size;
for (size_t i = 1; i < messages.size(); i++) {
const auto &msg = messages[i];
header_len = write_plaintext_header(buffer_data + msg.offset, msg.payload_size, msg.message_type, msg.header_size);
total_len += header_len + msg.payload_size;
if (state_ != State::DATA) {
return APIError::BAD_STATE;
}
return this->write_raw_fast_buf_(write_start, total_len);
if (messages.empty()) {
return APIError::OK;
}
uint8_t *buffer_data = buffer.get_buffer()->data();
// Stack-allocated iovec array - no heap allocation
StaticVector<struct iovec, MAX_MESSAGES_PER_BATCH> iovs;
uint16_t total_write_len = 0;
for (const auto &msg : messages) {
// Calculate varint sizes for header layout
uint8_t size_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(msg.payload_size));
uint8_t type_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(msg.message_type));
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
// Calculate where to start writing the header
// The header starts at the latest possible position to minimize unused padding
//
// Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3
// [0-2] - Unused padding
// [3] - 0x00 indicator byte
// [4] - Payload size varint (1 byte, for sizes 0-127)
// [5] - Message type varint (1 byte, for types 0-127)
// [6...] - Actual payload data
//
// Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2
// [0-1] - Unused padding
// [2] - 0x00 indicator byte
// [3-4] - Payload size varint (2 bytes, for sizes 128-16383)
// [5] - Message type varint (1 byte, for types 0-127)
// [6...] - Actual payload data
//
// Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0
// [0] - 0x00 indicator byte
// [1-3] - Payload size varint (3 bytes, for sizes 16384-2097151)
// [4-5] - Message type varint (2 bytes, for types 128-32767)
// [6...] - Actual payload data
//
// The message starts at offset + frame_header_padding_
// So we write the header starting at offset + frame_header_padding_ - total_header_len
uint8_t *buf_start = buffer_data + msg.offset;
uint32_t header_offset = frame_header_padding_ - total_header_len;
// Write the plaintext header
buf_start[header_offset] = 0x00; // indicator
// Encode varints directly into buffer
encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1);
encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len);
// Add iovec for this message (header + payload)
size_t msg_len = static_cast<size_t>(total_header_len + msg.payload_size);
iovs.push_back({buf_start + header_offset, msg_len});
total_write_len += msg_len;
}
// Send all messages in one writev call
return write_raw_(iovs.data(), iovs.size(), total_write_len);
}
} // namespace esphome::api
@@ -7,15 +7,13 @@ namespace esphome::api {
class APIPlaintextFrameHelper final : public APIFrameHelper {
public:
// Plaintext header structure (worst case):
// Pos 0: indicator (0x00)
// Pos 1-3: payload size varint (up to 3 bytes)
// Pos 4-5: message type varint (up to 2 bytes)
// Pos 6+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
explicit APIPlaintextFrameHelper(std::unique_ptr<socket::Socket> socket) : APIFrameHelper(std::move(socket)) {
frame_header_padding_ = HEADER_PADDING;
// Plaintext header structure (worst case):
// Pos 0: indicator (0x00)
// Pos 1-3: payload size varint (up to 3 bytes)
// Pos 4-5: message type varint (up to 2 bytes)
// Pos 6+: actual payload data
frame_header_padding_ = 6;
}
~APIPlaintextFrameHelper() override = default;
APIError init() override;
-26
View File
@@ -22,8 +22,6 @@ extend google.protobuf.MessageOptions {
optional bool log = 1039 [default=true];
optional bool no_delay = 1040 [default=false];
optional string base_class = 1041;
optional bool inline_encode = 1042 [default=false];
optional bool speed_optimized = 1043 [default=false];
}
extend google.protobuf.FieldOptions {
@@ -92,28 +90,4 @@ extend google.protobuf.FieldOptions {
// - uint16_t <field>_length_{0};
// - uint16_t <field>_count_{0};
optional bool packed_buffer = 50015 [default=false];
// force: Always encode this field, even when its value equals the proto3 default.
// Skips the zero/empty check in calculate_size() and encode(), using the _force
// variant of the calc_ method. Use on fields that are almost always non-default
// to eliminate dead branches on hot paths.
optional bool force = 50016 [default=false];
// max_value: Maximum value a field can have.
// When max_value < 128, the code generator emits constant-size calculations
// and direct byte writes instead of varint branching, since the encoded varint
// is guaranteed to be 1 byte.
optional uint32 max_value = 50017;
// max_data_length: Maximum length of a string or bytes field.
// When max_data_length < 128, the code generator emits constant-size
// length varint calculations and direct byte writes, since the length
// varint is guaranteed to be 1 byte.
optional uint32 max_data_length = 50018;
// mac_address: Field is a 48-bit MAC address stored in a uint64.
// Emits encode_varint_raw_48bit which has a 7-byte fast path that avoids
// the per-byte loop when the upper bits are non-zero (the common case
// for real MAC addresses, since OUIs occupy the top 24 bits).
optional bool mac_address = 50019 [default=false];
}
@@ -1,73 +0,0 @@
#include "api_overflow_buffer.h"
#ifdef USE_API
#include <cstring>
namespace esphome::api {
APIOverflowBuffer::~APIOverflowBuffer() {
for (auto *entry : this->queue_) {
if (entry != nullptr)
Entry::destroy(entry);
}
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
ssize_t sent = socket->write(front->current_data(), front->remaining());
if (sent <= 0) {
// -1 = error (caller checks errno for EWOULDBLOCK vs hard error)
// 0 = nothing sent (treat as no progress)
return sent;
}
if (static_cast<uint16_t>(sent) < front->remaining()) {
// Partially sent, update offset and stop
front->offset += static_cast<uint16_t>(sent);
return sent;
}
// Entry fully sent — free it and advance
Entry::destroy(front);
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
}
return 0; // All drained
}
bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) {
if (this->count_ >= API_MAX_SEND_QUEUE)
return false;
uint16_t buffer_size = total_len - skip;
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
this->queue_[this->tail_] = entry;
uint16_t to_skip = skip;
uint16_t write_pos = 0;
for (int i = 0; i < iovcnt; i++) {
if (to_skip >= iov[i].iov_len) {
to_skip -= static_cast<uint16_t>(iov[i].iov_len);
} else {
const uint8_t *src = reinterpret_cast<uint8_t *>(iov[i].iov_base) + to_skip;
uint16_t len = static_cast<uint16_t>(iov[i].iov_len) - to_skip;
std::memcpy(entry->data + write_pos, src, len);
write_pos += len;
to_skip = 0;
}
}
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
this->count_++;
return true;
}
} // namespace esphome::api
#endif // USE_API
@@ -1,76 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include <sys/types.h>
#include "esphome/core/defines.h"
#ifdef USE_API
#include "esphome/components/socket/headers.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/helpers.h"
namespace esphome::api {
/// Circular queue of heap-allocated byte buffers used as a TCP send backlog.
///
/// Under normal operation this buffer is **never used** — data goes straight
/// from the frame helper to the socket. It only fills when the LWIP TCP
/// send buffer is full (slow client, congested network, heavy logging).
/// The queue drains automatically on subsequent write/loop calls once the
/// socket becomes writable again.
///
/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python
/// config). If the queue fills completely the connection is marked failed.
class APIOverflowBuffer {
public:
/// A single heap-allocated send-backlog entry.
/// Lifetime is manually managed — see destroy().
struct Entry {
uint8_t *data;
uint16_t size; // Total size of the buffer
uint16_t offset; // Current send offset within the buffer
uint16_t remaining() const { return this->size - this->offset; }
const uint8_t *current_data() const { return this->data + this->offset; }
/// Free this entry and its data buffer.
static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) {
delete[] entry->data;
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
}
};
~APIOverflowBuffer();
/// True when no backlogged data is waiting.
bool empty() const { return this->count_ == 0; }
/// True when the queue has no room for another entry.
bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; }
/// Number of entries currently queued.
uint8_t count() const { return this->count_; }
/// Try to drain queued data to the socket.
/// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress,
/// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors).
/// Callers only need to act on -1; 0 and positive values both mean "no error".
/// Frees entries as they are fully sent.
ssize_t try_drain(socket::Socket *socket);
/// Enqueue unsent IOV data into the backlog.
/// Copies iov data starting at byte offset `skip` into a new entry.
/// Returns false if the queue is full (caller should fail the connection).
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
protected:
std::array<Entry *, API_MAX_SEND_QUEUE> queue_{};
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
};
} // namespace esphome::api
#endif // USE_API
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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