mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 16:32:11 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35cfb58bca | ||
|
|
e688972cb0 | ||
|
|
3b1bf1dcba | ||
|
|
72221aa0d3 | ||
|
|
70448afb87 | ||
|
|
6ba8862ad8 | ||
|
|
ca69cd06e9 | ||
|
|
c965c5ee70 | ||
|
|
97bbe58c8c | ||
|
|
1cc6c9d1dd | ||
|
|
f188d0c450 | ||
|
|
d4064d3f3d | ||
|
|
72c429bb0f | ||
|
|
a9f970ca67 | ||
|
|
1a50e1394a | ||
|
|
ae1f912a5f | ||
|
|
916798184d | ||
|
|
1e49616bc5 | ||
|
|
c66f71bb96 | ||
|
|
8db0774d67 | ||
|
|
f735c4634e | ||
|
|
fe125726ee | ||
|
|
e05f2fc74e | ||
|
|
4e68f1d324 | ||
|
|
98706c594b | ||
|
|
633c2b7f71 | ||
|
|
516511b346 | ||
|
|
f72dc7c329 | ||
|
|
f1695ac0b0 | ||
|
|
24c64a8efd | ||
|
|
d3638336ae | ||
|
|
53bffb560b | ||
|
|
2af39e6643 | ||
|
|
533a3afebd | ||
|
|
ddb7a6d9aa | ||
|
|
ae99accace | ||
|
|
474667d1a8 | ||
|
|
dbc93bb133 | ||
|
|
5486ef400f | ||
|
|
bd723f75af | ||
|
|
efba19ea3a | ||
|
|
6c68985c14 | ||
|
|
7be8a7c390 | ||
|
|
eb8cf139d7 | ||
|
|
ab6ebac628 | ||
|
|
6cb622417a | ||
|
|
960316abd6 | ||
|
|
87341dc136 |
No files matched your search
@@ -0,0 +1,215 @@
|
||||
---
|
||||
name: automations
|
||||
description: Write ESPHome automation building blocks - triggers (on_* keys), actions and conditions. Use when adding or changing an on_* trigger, an action such as my_component.do_something, a condition, a callback registration method (add_on_*_callback), or code that calls build_callback_automation, build_automation, register_*_action or register_*_condition.
|
||||
---
|
||||
|
||||
# Automations: triggers, actions and conditions
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do
|
||||
something), and **Conditions** (check if something is true). The helpers named here are documented in
|
||||
their docstrings in `esphome/automation.py`.
|
||||
|
||||
## 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
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only fire for
|
||||
some values, pass `params`, `forward` and `when`; the helper then generates a capture-less lambda (stored
|
||||
inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build `forward` from
|
||||
`MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++: no trigger class needed.** The callback registration method must be templatized to accept both
|
||||
`std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
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_;
|
||||
};
|
||||
```
|
||||
|
||||
Never type a registration method's parameter as `std::function`; that forces a heap allocation for every
|
||||
forwarder struct:
|
||||
```cpp
|
||||
// Bad
|
||||
void add_on_state_callback(std::function<void(bool)> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
```
|
||||
|
||||
| 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) |
|
||||
|
||||
Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and forwarder structs through
|
||||
their templatized `add()` method. `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.
|
||||
|
||||
## 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 it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and the
|
||||
`@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
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.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them with
|
||||
`register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover the
|
||||
rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a byte
|
||||
payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload stays in
|
||||
flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with `cg.templatable` stays
|
||||
for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
## 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_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares against
|
||||
config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: breaking-changes
|
||||
description: Decide whether an ESPHome change breaks public API and handle it - what counts as public C++ and Python API, the deprecation window, ESPDEPRECATED for C++, cv.rename_key and deprecation warnings for config keys, and the migration notes a PR needs. Use when removing, renaming or changing the signature of a config option, a public C++ member or a core Python helper.
|
||||
---
|
||||
|
||||
# Public API and breaking changes
|
||||
|
||||
Full policy: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
## What is public
|
||||
|
||||
- **C++ in components:** only features documented at [esphome.io](https://esphome.io) are public API.
|
||||
Undocumented `public` members are internal.
|
||||
- **C++ core and base classes** (`esphome/core/`, `Component`, `Sensor`, etc.): all `public` members are
|
||||
public API.
|
||||
- **Components with global accessors** (`global_api_server`, etc.): all `public` members are public API
|
||||
(except config setters).
|
||||
- **Python:** all documented configuration options are public API. Python code in `esphome/core/` that
|
||||
existing core components use is stable API. Other Python code is internal unless explicitly documented
|
||||
for external component use.
|
||||
- Changing the signature of a lambda that code generation injects is not a breaking change.
|
||||
|
||||
## Policy
|
||||
|
||||
- Aim for a **6-month deprecation window** when possible.
|
||||
- Clean breaks are allowed for signature changes, deep refactorings and resource constraints.
|
||||
- The migration path goes in the PR description (it generates the release notes).
|
||||
- A blog post is required for core/base class changes or significant architectural changes.
|
||||
|
||||
Checklist:
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (`ESPDEPRECATED` for C++)
|
||||
- [ ] Documented migration path in the PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during the deprecation period
|
||||
|
||||
## Deprecating C++
|
||||
|
||||
```cpp
|
||||
// Remove before 2026.6.0
|
||||
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
|
||||
void old_method() { this->new_method(); }
|
||||
```
|
||||
|
||||
## Deprecating config keys
|
||||
|
||||
For a renamed key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for
|
||||
context); it warns and migrates automatically:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
Cover the old syntax with a `validate.*.yaml` test (see the `component-tests` skill), and keep the language
|
||||
schema correct (see the `config-validation` skill).
|
||||
@@ -9,11 +9,13 @@ ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
|
||||
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
|
||||
flash are scarce, and code runs unattended for months.
|
||||
|
||||
`AGENTS.md` in the repository root is the full contributor guide and the
|
||||
authority when it disagrees with this summary. The developer documentation at
|
||||
https://developers.esphome.io explains the component lifecycle and the reasoning
|
||||
behind these rules. This skill lists the concrete things worth flagging in a
|
||||
review; read `AGENTS.md` for the detail behind any item.
|
||||
`AGENTS.md` in the repository root is the contributor guide, and the skills it
|
||||
lists (`component-codegen`, `config-validation`, `automations`,
|
||||
`embedded-memory`, `component-tests`, `breaking-changes`) hold the detail. They
|
||||
are the authority when they disagree with this summary. The developer
|
||||
documentation at https://developers.esphome.io explains the component lifecycle
|
||||
and the reasoning behind these rules. This skill lists the concrete things worth
|
||||
flagging in a review; read the matching skill for the detail behind any item.
|
||||
|
||||
Only raise findings that the diff actually introduces or changes. Do not ask for
|
||||
drive-by cleanup of pre-existing code the PR did not touch.
|
||||
@@ -76,6 +78,10 @@ performance nit, because it fragments a small shared heap. Flag:
|
||||
`if (blah := config.get(CONF_BLAH)) is not None:`.
|
||||
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
|
||||
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
|
||||
- A new or changed validator function that wraps, chooses or builds a schema
|
||||
must be decorated with `@schema_extractor(...)` and handle `SCHEMA_EXTRACT`,
|
||||
or `script/build_language_schema.py` silently drops the keys behind it (see
|
||||
the `config-validation` skill).
|
||||
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
|
||||
the component's own `.py`, or in `esphome/components/const/__init__.py` when
|
||||
shared. The same constant defined in three or more component files fails CI.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: component-codegen
|
||||
description: Write the Python side of an ESPHome component - __init__.py and platform files (sensor.py, switch.py, ...), DOMAIN and other metadata, to_code, hub child entities with sub_sensors and friends, pins and referenced components, hub-forwarding entities (ApplyButton, ApplySwitch), uart/i2c/spi constructor helpers, and CORE.data state. Use when creating a component or changing its code generation.
|
||||
---
|
||||
|
||||
# Component code generation
|
||||
|
||||
Read https://developers.esphome.io/architecture/components/ for the component lifecycle and how a
|
||||
component is registered. Schema rules and schema extraction are in the `config-validation` skill;
|
||||
automations (`on_*`, actions, conditions) in the `automations` skill.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
components/[component_name]/
|
||||
├── __init__.py # Component configuration schema and code generation
|
||||
├── [component].h # C++ header file (if needed)
|
||||
├── [component].cpp # C++ implementation (if needed)
|
||||
└── [platform]/ # Platform-specific implementations
|
||||
├── __init__.py # Platform-specific configuration
|
||||
├── [platform].h # Platform C++ header
|
||||
└── [platform].cpp # Platform C++ implementation
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
- `DOMAIN`: required in every component's `__init__.py`, set to the component's own name as a plain string
|
||||
(e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: list of required components.
|
||||
- `AUTO_LOAD`: components to automatically load. Not a way to put entities under the component's own key;
|
||||
see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: incompatible components.
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance.
|
||||
- `MULTI_CONF`: whether multiple instances are allowed.
|
||||
|
||||
## Basic pattern
|
||||
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_key(config[CONF_KEY]))
|
||||
cg.add(var.set_param(config[CONF_PARAM]))
|
||||
```
|
||||
|
||||
```cpp
|
||||
namespace esphome::my_component {
|
||||
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_key(const char *key) { this->key_ = StringRef(key); }
|
||||
void set_param(int param) { this->param_ = param; }
|
||||
|
||||
protected:
|
||||
StringRef key_;
|
||||
int param_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::my_component
|
||||
```
|
||||
|
||||
When the setter takes the value unchanged, use `cg.add_optional(var.set_blah, config.get(CONF_BLAH))`; it
|
||||
is not awaited, and it skips only `None`, so `0` and `False` are still set. Required, invariant
|
||||
dependencies are constructor arguments to `cg.new_Pvariable()`, not setters.
|
||||
|
||||
## Entity platforms
|
||||
|
||||
**Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
```
|
||||
|
||||
**Binary sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
|
||||
**Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
## Entities of a hub
|
||||
|
||||
A new component's entities go in platform files (`sensor.py`, `binary_sensor.py`, ...) that take the hub's
|
||||
id, never under the hub's own key with their platforms added to `AUTO_LOAD` to make that work. Auto loading
|
||||
a platform because the C++ uses its type is fine. See
|
||||
https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
**Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in
|
||||
their domains), adding `parent=hub` for entities that derive from `Parented<T>`, then make one call per
|
||||
key, even when there is only one. A call creates the entity only when its key is configured, passes it to
|
||||
the setter and returns it (or `None`); extra arguments such as `min_value` or `options` go on the call.
|
||||
Always name the setter explicitly on the object that owns it, never with `getattr` and an f-string, and
|
||||
keep that variable short (`var` for the component itself, `hub` for one fetched with `cg.get_variable`) so
|
||||
the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay
|
||||
as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
**Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the
|
||||
state. `publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off
|
||||
and runs the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)` to still
|
||||
drive a state when the restore mode is disabled. A hub that applies its switches' restored states itself
|
||||
reads `sw->get_initial_write_state()`, which already applies `inverted`.
|
||||
|
||||
**Entities that each watch a register field:** one class taking register, shift, mask and match from a
|
||||
Python tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a
|
||||
virtual listener base (a second vptr per entity).
|
||||
|
||||
## Other helpers
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for
|
||||
`set_parent`.
|
||||
- `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`, `spi.new_spi_component`,
|
||||
`modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`) constructs, registers and
|
||||
attaches in one call.
|
||||
- `display.add_writer(var, config)` sets the lambda.
|
||||
- `MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict.
|
||||
- `button.restart_button_schema(cls)` (and `factory_reset_`, `query_`) are button presets.
|
||||
- `fv.get_declaring_config(id)` finds a hub's config in final validation.
|
||||
|
||||
## State during code generation
|
||||
|
||||
Use `CORE.data` for component state that needs to persist during configuration generation, namespaced
|
||||
under the component's `DOMAIN` and held in a `@dataclass`. Avoid module-level mutable globals: they
|
||||
persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec, whereas
|
||||
`CORE.data` is cleared between runs.
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from esphome.core import CORE
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
```
|
||||
|
||||
Don't use flat keys such as `CORE.data["my_component_feature"] = True`. Some components use `TypedDict`
|
||||
instead of a dataclass; both are acceptable.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: component-tests
|
||||
description: Write and run tests for ESPHome components - YAML build tests (test.*.yaml, validate.*.yaml, common.yaml) under tests/components, shared bus packages and CI test grouping, C++ gtest unit tests, and host integration tests including the uart_mock fixture. Use when adding or changing files under tests/, or when a code change needs test coverage.
|
||||
---
|
||||
|
||||
# Component tests
|
||||
|
||||
## C++ unit tests
|
||||
|
||||
gtests live in `tests/components/<component>/*.cpp` and run with `script/cpp_unit_test.py <component>`; see
|
||||
`tests/components/README.md`. Set the loop clock in a test with `LoopBlockingGuard{nullptr, nullptr,
|
||||
now_ms}`; the runner resets it before every test.
|
||||
|
||||
## Integration tests
|
||||
|
||||
`tests/integration/` runs real firmware on the host. Serial components prove timeouts and recovery with the
|
||||
`uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its `responses` match on content every time
|
||||
they see it; to answer a repeated request differently, use an `on_tx` lambda with a static counter and
|
||||
`inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
|
||||
## YAML build tests
|
||||
|
||||
```
|
||||
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 # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them with `script/test_build_components`. Use `-c <component>` to test specific components and
|
||||
`-t <target>` for specific platforms.
|
||||
|
||||
- **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs on.
|
||||
A variant per board for platform-identical code only adds CI time.
|
||||
- **Config-only test files (`validate.*.yaml`):** use this prefix when a YAML file only needs to exercise
|
||||
schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with
|
||||
`esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml`: base config-only test
|
||||
- `validate-<variant>.<platform>.yaml`: config-only variant
|
||||
|
||||
Use this for deprecated-syntax migration tests, schema edge cases, or platform-specific validation
|
||||
branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and
|
||||
`validate.*.yaml` files. Validate files never participate in bus grouping; each one runs as its own
|
||||
`esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml`
|
||||
changes, and the component isn't pulled in as a dependency of another changed component), CI skips the
|
||||
compile stage for that component and only runs config validation. This is decided in
|
||||
`script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the
|
||||
`validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
## 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/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works; the
|
||||
grouping scripts only understand dict-style packages. Never use list-style packages
|
||||
(`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed
|
||||
by the bus name; the component's `common.yaml` is keyed by the component name (e.g.
|
||||
`cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml - everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !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. `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a
|
||||
standalone entity with its own id instead.
|
||||
|
||||
A test that needs a shared component in a particular state (`esp32_ble` security settings, an option that
|
||||
PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
|
||||
To verify that all components can be tested together without ID conflicts or configuration issues:
|
||||
```bash
|
||||
./script/test_component_grouping.py -e config --all
|
||||
```
|
||||
This tests all components in a single build to catch conflicts that might not appear when testing
|
||||
components individually. Use `-e config` for fast configuration validation, or `-e compile` for full
|
||||
compilation testing.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: config-validation
|
||||
description: Write or change ESPHome configuration validation - CONFIG_SCHEMA, cv.Schema, cv.typed_schema, custom validator functions, config keys and CONF_ constants, renamed or deprecated keys, and the language schema that script/build_language_schema.py extracts from them. Use whenever a change touches a component's schema or a validator in config_validation.py, automation.py or a component's .py files.
|
||||
---
|
||||
|
||||
# Configuration validation
|
||||
|
||||
ESPHome validates YAML with `voluptuous` schemas built from `esphome/config_validation.py` (`cv`). The
|
||||
same schemas are also walked by `script/build_language_schema.py`, which produces the JSON language schema
|
||||
that editors and the dashboard use for completion and checking. A validation change is only finished when
|
||||
both still work.
|
||||
|
||||
## Writing schemas
|
||||
|
||||
- **Type hints:** validators are typed like any other function: `def validate_x(config: ConfigType) ->
|
||||
ConfigType:`, with `ConfigType` imported from `esphome.types`.
|
||||
- **One schema per model:** when a component's options depend on a `model` key, use
|
||||
`cv.typed_schema({...}, key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not
|
||||
one schema plus a validator that rejects keys and fills defaults afterwards.
|
||||
- **Reuse existing validators:** before writing a custom validator, check for an existing one in
|
||||
`config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components.
|
||||
For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and
|
||||
reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See
|
||||
how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
- **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)`.
|
||||
- **Platform-specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`,
|
||||
`cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
- **Framework-specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
- **Schema extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
- **Constants:** `esphome/const.py` is frozen; do not add new `CONF_` constants there. Define a
|
||||
component-local constant in the component's own `.py`; for a constant shared by multiple components, add
|
||||
it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is
|
||||
defined in three or more component files. Constants used in core files (those not under
|
||||
`esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI check.
|
||||
- **Renamed or removed keys:** see the `breaking-changes` skill for `cv.rename_key` and manual
|
||||
deprecation warnings.
|
||||
- **Config-only tests:** schema edge cases and deprecated-syntax migrations go in `validate.*.yaml` test
|
||||
files (see the `component-tests` skill).
|
||||
|
||||
## Schema extraction
|
||||
|
||||
`script/build_language_schema.py` sets `esphome.schema_extractors.EnableSchemaExtraction = True` before it
|
||||
imports any component, then walks every `CONFIG_SCHEMA` (and platform schema) recursively. It understands
|
||||
`cv.Schema`, `cv.All`, `cv.Any`, dicts of `cv.Required` / `cv.Optional` keys, `.extend()`, `cv.ensure_list`,
|
||||
`cv.typed_schema`, registries and the known `cv` validators. **A plain function it cannot see into**: a
|
||||
custom validator that builds or picks a schema at validation time hides every key behind it, and the
|
||||
dumper silently emits nothing for them. CI only runs the script with `--check`, which does not compare the
|
||||
output, so a lost schema is not caught.
|
||||
|
||||
When a validator function wraps, chooses or builds a schema (rather than checking a single value), make it
|
||||
visible to the dumper:
|
||||
|
||||
1. Decorate it with `@schema_extractor("<type>")` from `esphome.schema_extractors`.
|
||||
2. At the top of the function, return the data for that type when it is called with `SCHEMA_EXTRACT`
|
||||
instead of a config value. Compare with `is` (`value is SCHEMA_EXTRACT`); older code uses `==`.
|
||||
|
||||
```python
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
```
|
||||
|
||||
The type names the dumper accepts, and what `SCHEMA_EXTRACT` must return for each, are in the
|
||||
`hidden_schemas` branch of `convert()` in `script/build_language_schema.py`. The common ones:
|
||||
|
||||
| Type | Return for `SCHEMA_EXTRACT` | Example |
|
||||
|------|-----------------------------|---------|
|
||||
| `"schema"` | the schema (or a representative one) to walk | `api` `_outgoing_connection_schema`, `restore_state_schema` in `esphome/core/entity_helpers.py`, `model_schema_extractor` in `esphome/components/mipi/__init__.py` |
|
||||
| `"enum"` | the dict of options | `sensor_entity_category` in `esphome/components/sensor/__init__.py` |
|
||||
| `"one_of"` | the list of options | `cv.one_of` |
|
||||
| `"maybe"` | `(validator, key)` for a scalar shorthand | `cv.maybe_simple_value`, `maybe_conf` in `esphome/automation.py` |
|
||||
| `"templatable"` | the inner validator | `cv.templatable` |
|
||||
| `"automation"` | the automation schema | `automation.validate_automation` |
|
||||
|
||||
Points to watch:
|
||||
|
||||
- The dumper matches the function object by `repr`, so the decorated function must be the exact object
|
||||
placed in the schema. In a factory that returns a new closure per call, decorate the inner function (as
|
||||
`restore_state_schema` and `cv.maybe_simple_value` do), not the factory.
|
||||
- The decorator only records anything when extraction is enabled, and that flag is set before imports, so
|
||||
the decorator must run at import time (module level or inside a factory called at module level).
|
||||
- A schema chosen per config value (for example by `model`) has no single answer: return a representative
|
||||
schema, as `model_schema_extractor` does.
|
||||
- Plain value checks (`cv.All(cv.string, my_check)`) need nothing: the dumper still sees the surrounding
|
||||
types and keys.
|
||||
- `cv.Schema`, `cv.typed_schema`, `cv.ensure_list` and the `cv` helpers are already handled; prefer
|
||||
composing them over a custom function, which also avoids the extractor work.
|
||||
|
||||
### Checking the output
|
||||
|
||||
Build the language schema into a scratch directory and look at the component's JSON (it takes about ten
|
||||
seconds):
|
||||
|
||||
```bash
|
||||
python3 script/build_language_schema.py --output-path /tmp/schema
|
||||
python3 -c "import json; print(json.dumps(json.load(open('/tmp/schema/my_component.json')), indent=1))" | less
|
||||
```
|
||||
|
||||
Every key a user can write should appear under `config_vars` with a sensible `type`; compare against the
|
||||
same output from `dev` when changing an existing schema. If the script prints `Unknown extracted schema
|
||||
type` or raises, the decorator type or the value returned for `SCHEMA_EXTRACT` is wrong. Unit tests for
|
||||
the dumper live in `tests/script/test_build_language_schema.py`.
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: embedded-memory
|
||||
description: Choose ESPHome C++ containers and storage that avoid heap use and fragmentation on microcontrollers - std::array, StaticVector with cg.slot_counter, FixedVector, std::unique_ptr byte buffers, StringRef, RAMAllocator, and what to avoid (std::vector growth, std::map, std::deque, std::string, new (std::nothrow)). Use when adding or changing C++ members, containers, buffers, strings or registration lists, or when reducing RAM or flash use.
|
||||
---
|
||||
|
||||
# Memory on embedded targets
|
||||
|
||||
ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over
|
||||
time, repeated allocations of different sizes fragment the heap. Failures happen when the largest
|
||||
contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by
|
||||
this.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** Every
|
||||
allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a
|
||||
long-term reliability bug, not a performance issue. Helpers that hide allocation (`std::string`,
|
||||
`std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based
|
||||
APIs.
|
||||
|
||||
## Container guidelines
|
||||
|
||||
1. **Compile-time-known sizes:** use `std::array` instead of `std::vector` when size is known at compile
|
||||
time.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code
|
||||
std::vector<int> values;
|
||||
|
||||
// Good - no dynamic allocation
|
||||
std::array<int, MAX_VALUES> values;
|
||||
```
|
||||
Use `cg.add_define("MAX_VALUES", count)` to set the size from Python configuration.
|
||||
|
||||
**For byte buffers:** avoid `std::vector<uint8_t>` unless the buffer needs to grow. Use
|
||||
`std::unique_ptr<uint8_t[]>` instead. It does **not** provide bounds checking or iterator support like
|
||||
`std::vector<uint8_t>`; use it only when you do not need these features.
|
||||
```cpp
|
||||
// Bad - STL overhead for simple byte buffer
|
||||
std::vector<uint8_t> buffer;
|
||||
buffer.resize(256);
|
||||
|
||||
// Good - minimal overhead, single allocation
|
||||
std::unique_ptr<uint8_t[]> buffer = std::make_unique<uint8_t[]>(256);
|
||||
// Or if size is constant:
|
||||
std::array<uint8_t, 256> buffer;
|
||||
```
|
||||
|
||||
2. **Compile-time-known fixed sizes with vector-like API:** use `StaticVector` from
|
||||
`esphome/core/helpers.h` for compile-time fixed size with a `push_back()` interface (no dynamic
|
||||
allocation).
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<ServiceRecord> services;
|
||||
services.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - compile-time fixed size, no dynamic allocation
|
||||
StaticVector<ServiceRecord, MAX_SERVICES> services;
|
||||
services.push_back(record1);
|
||||
```
|
||||
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that is
|
||||
known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it returns a
|
||||
function that each consumer calls once per slot it will occupy, and after every `to_code` has run it
|
||||
emits the define with the final count. When nothing registers, no define is emitted and the storage plus
|
||||
its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a `MULTI_CONF`
|
||||
component), pass the owning object as the key, `_request_listener_slot(str(hub))`; the define is then
|
||||
the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL`; a late request
|
||||
raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** use `FixedVector` from `esphome/core/helpers.h` when the size is only known at
|
||||
runtime initialization.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<TxtRecord> txt_records;
|
||||
txt_records.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - runtime size, single allocation, no reallocation machinery
|
||||
FixedVector<TxtRecord> txt_records;
|
||||
txt_records.init(record_count); // Initialize with exact size at runtime
|
||||
```
|
||||
This eliminates `_M_realloc_insert` and `_M_default_append` template instantiations (200-500 bytes per
|
||||
instance), needs no upper bound, and is compatible with protobuf code generation when using
|
||||
`[(fixed_vector) = true]`.
|
||||
|
||||
4. **Small datasets (1-16 elements):** use `std::vector` or `std::array` with simple structs instead of
|
||||
`std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// Bad - 2KB+ overhead for red-black tree/hash table
|
||||
std::map<std::string, int> small_lookup;
|
||||
std::unordered_map<int, std::string> tiny_map;
|
||||
|
||||
// Good - simple struct with linear search (std::vector is fine)
|
||||
struct LookupEntry {
|
||||
const char *key;
|
||||
int value;
|
||||
};
|
||||
std::vector<LookupEntry> small_lookup = {
|
||||
{"key1", 10},
|
||||
{"key2", 20},
|
||||
{"key3", 30},
|
||||
};
|
||||
// Or std::array if size is compile-time constant:
|
||||
// std::array<LookupEntry, 3> small_lookup = {{ ... }};
|
||||
```
|
||||
Linear search on small datasets is often faster than hashing or tree overhead, but this depends on
|
||||
lookup frequency; in hot paths the O(1) vs O(n) difference may still matter. It is the heavy containers
|
||||
(`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows
|
||||
otherwise.
|
||||
|
||||
5. **Strings set once from configuration:** use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the program,
|
||||
so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a non-owning pointer plus
|
||||
length; it does not copy, and it must only ever refer to storage that outlives it (a string literal, or
|
||||
a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **Avoid `std::deque`:** it allocates in 512-byte blocks regardless of element size, guaranteeing at
|
||||
least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained
|
||||
devices.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** on ESP-IDF exceptions are disabled, so a failed nothrow allocation
|
||||
aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects
|
||||
`std::nothrow`.
|
||||
|
||||
8. **Detection:** look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
Callback storage (`CallbackManager` vs `LazyCallbackManager`) and templated registration methods are
|
||||
covered in the `automations` skill.
|
||||
|
||||
## Where to spend effort
|
||||
|
||||
Prioritize container optimization for core components (API, network, logger), widely used components
|
||||
(mdns, wifi, ble), and components causing flash size complaints. Avoiding heap allocation after `setup()`
|
||||
is always required regardless of component type; the priority list is about effort spent on container
|
||||
migration (e.g. `std::vector` to `StaticVector`).
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Install apt packages (cached)
|
||||
description: >-
|
||||
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
|
||||
only from the package list and version, so both live here and nowhere else.
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Keep a failed install out of the cache
|
||||
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
|
||||
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
|
||||
# already on PATH: this one refreshes the lists and retries when the install fails and,
|
||||
# if that fails too, removes the folder the action would save. Only a cache miss installs.
|
||||
shell: bash
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
|
||||
#!/bin/bash
|
||||
if [[ " $* " != *" install "* ]]; then
|
||||
exec apt-get "$@"
|
||||
fi
|
||||
touch /tmp/apt-fast-standin-ran
|
||||
apt-get "$@" && exit 0
|
||||
apt-get update -qq >&2
|
||||
# The action pins each package to the version the stale lists named
|
||||
args=()
|
||||
for arg in "$@"; do
|
||||
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
|
||||
done
|
||||
apt-get "${args[@]}" && exit 0
|
||||
# The action writes our output to install.log inside the folder it saves
|
||||
log="$(readlink -f "/proc/$$/fd/1")"
|
||||
if [ "$(basename "${log}")" = install.log ]; then
|
||||
rm -rf "$(dirname "${log}")"
|
||||
else
|
||||
touch /tmp/apt-fast-cleanup-missed
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
sudo chmod +x /usr/local/bin/apt-fast
|
||||
- name: Install apt packages (cached)
|
||||
id: apt
|
||||
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
|
||||
# release calls apt-fast and where it writes install.log.
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
# 1.3 drops the empty caches saved before the step above existed
|
||||
version: 1.3
|
||||
- name: Check the packages are installed
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
|
||||
run: |
|
||||
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
|
||||
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
|
||||
exit 1
|
||||
fi
|
||||
if [ -e /tmp/apt-fast-cleanup-missed ]; then
|
||||
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
|
||||
exit 1
|
||||
fi
|
||||
# The action restores files without registering them with dpkg
|
||||
for cmd in ccache sdl2-config; do
|
||||
if ! command -v "${cmd}" > /dev/null; then
|
||||
echo "::error::${cmd} is missing after the cached install"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ! -f /usr/include/mbedtls/version.h ]; then
|
||||
echo "::error::libmbedtls-dev is missing after the cached install"
|
||||
exit 1
|
||||
fi
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -154,14 +154,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+25
-36
@@ -34,7 +34,7 @@ jobs:
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -73,16 +73,17 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
||||
# pushes seed the one shared entry PR jobs restore. The key is derived
|
||||
# only from the package list and version; keep both identical in every
|
||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
||||
# only from the package list and version, kept in the apt-packages action.
|
||||
# In ci-status needs so a broken seed fails dev.
|
||||
if: github.event_name == 'push'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
sparse-checkout: .github
|
||||
- name: Install apt packages (cached)
|
||||
uses: ./.github/actions/apt-packages
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
@@ -412,17 +413,13 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly.
|
||||
# libsdl2-dev is needed by the headless display tests, which capture
|
||||
# screenshots.
|
||||
# path. libsdl2-dev is needed by the headless display tests, which
|
||||
# capture screenshots.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
uses: ./.github/actions/apt-packages
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore integration library cache
|
||||
@@ -441,7 +438,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -630,7 +627,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
|
||||
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -718,13 +715,9 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
if: matrix.pio_cache_key == 'tidyhost'
|
||||
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
||||
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
|
||||
# exactly.
|
||||
# by dsmr and dlms_meter.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
uses: ./.github/actions/apt-packages
|
||||
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
@@ -1111,20 +1104,16 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install apt packages (cached)
|
||||
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
||||
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
||||
# the cold path. Packages and version must match seed-apt-cache
|
||||
# exactly. The action has no --no-install-recommends; same package
|
||||
# set this job used before #17463.
|
||||
# the cold path. The action has no --no-install-recommends; same
|
||||
# package set this job used before #17463.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: ./.github/actions/apt-packages
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1457,7 +1446,7 @@ jobs:
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -1466,7 +1455,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -206,13 +206,13 @@ jobs:
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.1
|
||||
rev: v0.17.2
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -54,6 +54,19 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each.
|
||||
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
|
||||
|
||||
* **Skills:** detailed recipes live in skills under `.agents/skills/` (also reachable as `.claude/skills/` and
|
||||
`.github/skills/`). Load the matching skill before doing that kind of work; agents without skill support
|
||||
should read the `SKILL.md` file directly.
|
||||
* `component-codegen`: component files and metadata, `to_code`, hub child entities, codegen helpers,
|
||||
`CORE.data` state.
|
||||
* `config-validation`: schemas, validators, `CONF_` constants, and keeping schema extraction working.
|
||||
* `automations`: triggers, actions, conditions and callback registration.
|
||||
* `embedded-memory`: container and string choices that avoid heap use after `setup()`.
|
||||
* `component-tests`: YAML build tests, bus packages and CI grouping, C++ unit tests, integration tests.
|
||||
* `breaking-changes`: what is public API, deprecation patterns and migration notes.
|
||||
* `pr-workflow`, `code-review`, `hardware-test`, `display-test`: creating PRs, reviewing them, and testing
|
||||
on real boards and panels.
|
||||
|
||||
* **Formatting:**
|
||||
* **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`.
|
||||
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
|
||||
@@ -226,9 +239,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
@@ -279,398 +289,28 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
};
|
||||
```
|
||||
|
||||
* **Component Structure:**
|
||||
* **Standard Files:**
|
||||
```
|
||||
components/[component_name]/
|
||||
├── __init__.py # Component configuration schema and code generation
|
||||
├── [component].h # C++ header file (if needed)
|
||||
├── [component].cpp # C++ implementation (if needed)
|
||||
└── [platform]/ # Platform-specific implementations
|
||||
├── __init__.py # Platform-specific configuration
|
||||
├── [platform].h # Platform C++ header
|
||||
└── [platform].cpp # Platform C++ implementation
|
||||
```
|
||||
* **Component Code Generation:** every component's `__init__.py` defines `DOMAIN` (its own name, checked by
|
||||
CI). Hub entities go in platform files (`sensor.py`, ...) that take the hub's id, not under the hub's own
|
||||
key. File layout, metadata, the `to_code` pattern, `sub_sensors` and the other codegen helpers are in the
|
||||
`component-codegen` skill.
|
||||
|
||||
* **Component Metadata:**
|
||||
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
|
||||
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: List of required components
|
||||
- `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's
|
||||
own key; see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: Incompatible components
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance
|
||||
- `MULTI_CONF`: Whether multiple instances are allowed
|
||||
|
||||
* **Code Generation & Common Patterns:**
|
||||
* **Configuration Schema Pattern:**
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_key(config[CONF_KEY]))
|
||||
cg.add(var.set_param(config[CONF_PARAM]))
|
||||
```
|
||||
|
||||
* **C++ Class Pattern:**
|
||||
```cpp
|
||||
namespace esphome::my_component {
|
||||
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_key(const std::string &key) { this->key_ = key; }
|
||||
void set_param(int param) { this->param_ = param; }
|
||||
|
||||
protected:
|
||||
std::string key_;
|
||||
int param_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::my_component
|
||||
```
|
||||
|
||||
* **Common Component Examples:**
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
```
|
||||
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
- **Entities of a hub:** a new component's entities go in platform files (`sensor.py`,
|
||||
`binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms
|
||||
added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is
|
||||
fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
- **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`,
|
||||
`sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented<T>`,
|
||||
then make one call per key, even when there is only one. A call creates the entity only when its key
|
||||
is configured, passes it to the setter and returns it (or `None`); extra arguments such as
|
||||
`min_value` or `options` go on the call. Always name the setter explicitly on the object that owns
|
||||
it, never with `getattr` and an f-string, and keep that variable short (`var` for the component
|
||||
itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose
|
||||
setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
|
||||
|
||||
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
|
||||
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
|
||||
the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
|
||||
already applies `inverted`.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda;
|
||||
`MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and
|
||||
`factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in
|
||||
final validation.
|
||||
|
||||
* **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
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only
|
||||
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
|
||||
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
|
||||
`forward` from `MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
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 it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and
|
||||
the `@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
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.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them
|
||||
with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
|
||||
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
|
||||
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
* **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_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares
|
||||
against config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
* **Automations (Triggers, Actions, Conditions):** prefer `build_callback_automation()` over a `Trigger`
|
||||
class, and `register_apply_action` / `register_apply_condition` over a C++ class that only forwards to its
|
||||
parent. Callback registration methods are always `template<typename F>`, never `std::function`. Details
|
||||
and examples are in the `automations` skill.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **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)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
* **Schema Extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
* **Configuration Validation:** follow the `config-validation` skill. In short: one `cv.typed_schema` per
|
||||
model, reuse validators from `config_validation.py`, and never add `CONF_` constants to the frozen
|
||||
`esphome/const.py`.
|
||||
* **Schema extraction:** whenever config validation code is added or changed, make sure
|
||||
`script/build_language_schema.py` still extracts the full schema. The dumper cannot see inside a plain
|
||||
function, so a validator function that wraps, chooses or builds a schema must be decorated with
|
||||
`@schema_extractor(...)` and return the schema data when called with `SCHEMA_EXTRACT` (both from
|
||||
`esphome.schema_extractors`). CI does not catch a missing schema; build the schema and check the
|
||||
component's JSON as the `config-validation` skill describes.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -690,71 +330,12 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **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 # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
|
||||
* **Component tests:** YAML build tests live in `tests/components/<component>/` and run with
|
||||
`script/test_build_components -c <component>`. Never define buses directly in test YAML: include the
|
||||
shared packages from `tests/test_build_components/common/` with dict-style `packages:`. Test file naming,
|
||||
`validate.*.yaml` config-only tests, CI grouping, C++ unit tests and integration tests are in the
|
||||
`component-tests` skill.
|
||||
|
||||
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml` — base config-only test
|
||||
- `validate-<variant>.<platform>.yaml` — config-only variant
|
||||
|
||||
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **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/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !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.
|
||||
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
./script/test_component_grouping.py -e config --all
|
||||
```
|
||||
This tests all components in a single build to catch conflicts that might not appear when testing components individually. Use `-e config` for fast configuration validation, or `-e compile` for full compilation testing.
|
||||
* **Debugging and Troubleshooting:**
|
||||
* **Debug Tools:**
|
||||
- `esphome config <file>.yaml` to validate configuration.
|
||||
@@ -768,7 +349,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 7. Specific Instructions for AI Collaboration
|
||||
|
||||
* **Contribution Workflow (Pull Request Process):**
|
||||
* **Contribution Workflow (Pull Request Process):** the `pr-workflow` skill walks through these steps.
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
@@ -788,236 +369,15 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Configuration Design:** Aim for simplicity with sensible defaults, while allowing for advanced customization.
|
||||
* **Embedded Systems Optimization:** ESPHome targets resource-constrained microcontrollers. Be mindful of flash size and RAM usage.
|
||||
|
||||
**Why Heap Allocation Matters:**
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** ESP devices run for
|
||||
months with small shared heaps, and repeated allocations fragment them until a large allocation fails; we
|
||||
have seen field crashes caused by this. ESPHome treats runtime heap allocation as a long-term reliability
|
||||
bug, not a performance issue. Prefer `std::array`, `StaticVector` (sized with `cg.slot_counter()` for
|
||||
registration lists), `FixedVector` and `StringRef` over growing `std::vector`, `std::map`, `std::deque`
|
||||
and `std::string`. The `embedded-memory` skill has the full container guidelines.
|
||||
|
||||
ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** Every allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a long-term reliability bug, not a performance issue. Helpers that hide allocation (`std::string`, `std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based APIs.
|
||||
|
||||
**STL Container Guidelines:**
|
||||
|
||||
ESPHome runs on embedded systems with limited resources. Choose containers carefully:
|
||||
|
||||
1. **Compile-time-known sizes:** Use `std::array` instead of `std::vector` when size is known at compile time.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code
|
||||
std::vector<int> values;
|
||||
|
||||
// Good - no dynamic allocation
|
||||
std::array<int, MAX_VALUES> values;
|
||||
```
|
||||
Use `cg.add_define("MAX_VALUES", count)` to set the size from Python configuration.
|
||||
|
||||
**For byte buffers:** Avoid `std::vector<uint8_t>` unless the buffer needs to grow. Use `std::unique_ptr<uint8_t[]>` instead.
|
||||
|
||||
> **Note:** `std::unique_ptr<uint8_t[]>` does **not** provide bounds checking or iterator support like `std::vector<uint8_t>`. Use it only when you do not need these features and want minimal overhead.
|
||||
|
||||
```cpp
|
||||
// Bad - STL overhead for simple byte buffer
|
||||
std::vector<uint8_t> buffer;
|
||||
buffer.resize(256);
|
||||
|
||||
// Good - minimal overhead, single allocation
|
||||
std::unique_ptr<uint8_t[]> buffer = std::make_unique<uint8_t[]>(256);
|
||||
// Or if size is constant:
|
||||
std::array<uint8_t, 256> buffer;
|
||||
```
|
||||
|
||||
2. **Compile-time-known fixed sizes with vector-like API:** Use `StaticVector` from `esphome/core/helpers.h` for compile-time fixed size with `push_back()` interface (no dynamic allocation).
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<ServiceRecord> services;
|
||||
services.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - compile-time fixed size, no dynamic allocation
|
||||
StaticVector<ServiceRecord, MAX_SERVICES> services;
|
||||
services.push_back(record1);
|
||||
```
|
||||
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
|
||||
Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that
|
||||
is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it
|
||||
returns a function that each consumer calls once per slot it will occupy, and after every
|
||||
`to_code` has run it emits the define with the final count. When nothing registers, no define is
|
||||
emitted and the storage plus its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a
|
||||
`MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`;
|
||||
the define is then the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late
|
||||
request raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<TxtRecord> txt_records;
|
||||
txt_records.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - runtime size, single allocation, no reallocation machinery
|
||||
FixedVector<TxtRecord> txt_records;
|
||||
txt_records.init(record_count); // Initialize with exact size at runtime
|
||||
```
|
||||
**Benefits:**
|
||||
- Eliminates `_M_realloc_insert`, `_M_default_append` template instantiations (saves 200-500 bytes per instance)
|
||||
- Single allocation, no upper bound needed
|
||||
- No reallocation overhead
|
||||
- Compatible with protobuf code generation when using `[(fixed_vector) = true]` option
|
||||
|
||||
4. **Small datasets (1-16 elements):** Use `std::vector` or `std::array` with simple structs instead of `std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// Bad - 2KB+ overhead for red-black tree/hash table
|
||||
std::map<std::string, int> small_lookup;
|
||||
std::unordered_map<int, std::string> tiny_map;
|
||||
|
||||
// Good - simple struct with linear search (std::vector is fine)
|
||||
struct LookupEntry {
|
||||
const char *key;
|
||||
int value;
|
||||
};
|
||||
std::vector<LookupEntry> small_lookup = {
|
||||
{"key1", 10},
|
||||
{"key2", 20},
|
||||
{"key3", 30},
|
||||
};
|
||||
// Or std::array if size is compile-time constant:
|
||||
// std::array<LookupEntry, 3> small_lookup = {{ ... }};
|
||||
```
|
||||
Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise.
|
||||
|
||||
5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the
|
||||
program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a
|
||||
non-owning pointer plus length; it does not copy, and it must only ever refer to storage that
|
||||
outlives it (a string literal, or a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`.
|
||||
|
||||
8. **Detection:** Look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
**Prioritize optimization effort for:**
|
||||
- Core components (API, network, logger)
|
||||
- Widely-used components (mdns, wifi, ble)
|
||||
- Components causing flash size complaints
|
||||
|
||||
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):**
|
||||
```python
|
||||
# Don't do this - state persists between compilation runs
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
```
|
||||
|
||||
**Bad Pattern (Flat Keys):**
|
||||
```python
|
||||
# Don't do this - keys should be namespaced under component domain
|
||||
MY_FEATURE_KEY = "my_component_feature"
|
||||
CORE.data[MY_FEATURE_KEY] = True
|
||||
```
|
||||
|
||||
**Good Pattern (dataclass):**
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from esphome.core import CORE
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
|
||||
If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs.
|
||||
|
||||
**Why this matters:**
|
||||
- Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec
|
||||
- `CORE.data` automatically clears between runs
|
||||
- Namespacing under `DOMAIN` prevents key collisions between components
|
||||
- `@dataclass` provides type safety and cleaner attribute access
|
||||
* **State Management:** keep code generation state in `CORE.data`, namespaced under the component's
|
||||
`DOMAIN` in a `@dataclass`, never in module-level mutable globals (see the `component-codegen` skill).
|
||||
|
||||
* **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
|
||||
|
||||
@@ -1028,57 +388,11 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 8. Public API and Breaking Changes
|
||||
|
||||
* **Public C++ API:**
|
||||
* **Components**: Only documented features at [esphome.io](https://esphome.io) are public API. Undocumented `public` members are internal.
|
||||
* **Core/Base Classes** (`esphome/core/`, `Component`, `Sensor`, etc.): All `public` members are public API.
|
||||
* **Components with Global Accessors** (`global_api_server`, etc.): All `public` members are public API (except config setters).
|
||||
* Only features documented at [esphome.io](https://esphome.io) are public API for components; all `public`
|
||||
members of `esphome/core/` and base classes are public API. Aim for a 6-month deprecation window
|
||||
(`ESPDEPRECATED` in C++, `cv.rename_key(..., removed_in=...)` for config keys) and document the migration
|
||||
path in the PR description. The `breaking-changes` skill has the full policy, checklist and patterns.
|
||||
|
||||
* **Public Python API:**
|
||||
* All documented configuration options at [esphome.io](https://esphome.io) are public API.
|
||||
* Python code in `esphome/core/` actively used by existing core components is considered stable API.
|
||||
* Other Python code is internal unless explicitly documented for external component use.
|
||||
|
||||
* **Breaking Changes Policy:**
|
||||
* Aim for **6-month deprecation window** when possible
|
||||
* Clean breaks allowed for: signature changes, deep refactorings, resource constraints
|
||||
* Must document migration path in PR description (generates release notes)
|
||||
* Blog post required for core/base class changes or significant architectural changes
|
||||
* Full details: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
* **Breaking Change Checklist:**
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
|
||||
- [ ] Documented migration path in PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during deprecation period
|
||||
|
||||
* **Deprecation Pattern (C++):**
|
||||
```cpp
|
||||
// Remove before 2026.6.0
|
||||
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
|
||||
void old_method() { this->new_method(); }
|
||||
```
|
||||
|
||||
* **Deprecation Pattern (Python):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
|
||||
@@ -439,6 +439,7 @@ 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_monitor/* @Bascht74
|
||||
esphome/components/modbus_server/* @exciton
|
||||
esphome/components/modem/* @oarcher
|
||||
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
|
||||
|
||||
+4
-6
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Protocol
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.build_helpers.native import analysis_backend, native_backend
|
||||
from esphome.build_helpers.native import native_backend
|
||||
from esphome.const import (
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
@@ -2138,7 +2138,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
analysis_toolchain = analysis_backend()
|
||||
analysis_toolchain = native_backend()
|
||||
if analysis_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
@@ -2147,11 +2147,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
if (
|
||||
check_supported := getattr(analysis_toolchain, "check_analysis_supported", None)
|
||||
) is not None:
|
||||
if hasattr(analysis_toolchain, "check_analysis_supported"):
|
||||
# Raises with the reason; before the compile, not after it
|
||||
check_supported()
|
||||
analysis_toolchain.check_analysis_supported()
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
|
||||
@@ -25,6 +25,7 @@ from esphome.framework_helpers import (
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,6 +155,47 @@ def has_discovered_components() -> bool:
|
||||
return bool(get_available_components())
|
||||
|
||||
|
||||
_KEY_WARNED_BUILD_FLAGS = "warned_build_flags"
|
||||
|
||||
|
||||
def _lexed_build_flags() -> list[str]:
|
||||
"""Shell-lex the build flag entries as the other generators do, so an
|
||||
entry holding several flags is classified flag by flag. Warns once per
|
||||
entry about a flag that native ESP-IDF builds do not pass on."""
|
||||
warned: set[tuple[str, str]] = CORE.data.setdefault(KEY_ESP32, {}).setdefault(
|
||||
_KEY_WARNED_BUILD_FLAGS, set()
|
||||
)
|
||||
flags: list[str] = []
|
||||
for entry in sorted(CORE.build_flags):
|
||||
tokens = lex_build_flags(entry, "esphome build_flags")
|
||||
for token in tokens:
|
||||
if (
|
||||
(token, entry) not in warned
|
||||
and not get_project_compile_flags([token])
|
||||
and not get_project_link_flags([token])
|
||||
):
|
||||
warned.add((token, entry))
|
||||
_LOGGER.warning(
|
||||
"Build flag '%s'%s is ignored: native ESP-IDF builds only "
|
||||
"pass -D, -W and -Wl, flags",
|
||||
token,
|
||||
"" if token == entry else f" in '{entry}'",
|
||||
)
|
||||
flags += tokens
|
||||
return flags
|
||||
|
||||
|
||||
def _cmake_args(flags: list[str]) -> list[str]:
|
||||
"""Quote each flag as a CMake argument so quotes, backslashes and $
|
||||
survive."""
|
||||
return [
|
||||
'"{}"'.format(
|
||||
flag.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
||||
)
|
||||
for flag in flags
|
||||
]
|
||||
|
||||
|
||||
def _cmake_quote(value: str) -> str:
|
||||
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||
@@ -294,18 +336,24 @@ def get_project_cmakelists(
|
||||
# esphome__micro-mp3) rather than just src/. Required so suppressions
|
||||
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
|
||||
# third-party components we don't author.
|
||||
project_compile_opts = get_project_compile_flags()
|
||||
# CMake keeps only the first copy of a repeated option; drop all but the
|
||||
# last copy instead so the last definition wins, as on the command line.
|
||||
project_compile_opts = list(
|
||||
reversed(
|
||||
dict.fromkeys(reversed(get_project_compile_flags(_lexed_build_flags())))
|
||||
)
|
||||
)
|
||||
extra_compile_options = "\n".join(
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(project_compile_opts)
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(get_project_cxx_compile_flags())
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
@@ -461,7 +509,7 @@ def get_component_cmakelists() -> str:
|
||||
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
|
||||
# emitted project-wide via idf_build_set_property in
|
||||
# get_project_cmakelists so they reach every component, not just src/.
|
||||
link_opts = get_project_link_flags()
|
||||
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
|
||||
link_opts_str = "\n ".join(link_opts) if link_opts else ""
|
||||
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ def _split_command(command: str) -> list[str]:
|
||||
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
|
||||
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
||||
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
|
||||
argc = ctypes.c_int()
|
||||
argc = ctypes.c_int(0)
|
||||
argv = CommandLineToArgvW(command, ctypes.byref(argc))
|
||||
if not argv: # pragma: no cover
|
||||
raise ctypes.WinError()
|
||||
@@ -296,10 +296,10 @@ def load_or_build_idedata(
|
||||
) -> dict | None:
|
||||
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||
|
||||
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||
is the compiler-launcher path (ccache) the build was generated with, if
|
||||
any; commands in the compile DB are prefixed with it.
|
||||
Shared by the native toolchains. Returns None when the compile DB
|
||||
doesn't exist yet (nothing was built). ``launcher`` is the
|
||||
compiler-launcher path (ccache) the build was generated with, if any;
|
||||
commands in the compile DB are prefixed with it.
|
||||
"""
|
||||
if not compile_commands.is_file():
|
||||
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||
|
||||
@@ -22,6 +22,7 @@ NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
("host", Toolchain.HOST): "esphome.host.toolchain",
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
@@ -39,24 +40,6 @@ def native_backend() -> ModuleType | None:
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
# Binutils and the linked image for memory analysis, for toolchains that build
|
||||
# without PlatformIO but have no native build backend (which supplies them)
|
||||
ANALYSIS_TOOLCHAIN_MODULES = {
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def analysis_backend() -> ModuleType | None:
|
||||
"""The module giving objdump, readelf and the ELF of a non-PlatformIO build.
|
||||
|
||||
None means PlatformIO's idedata supplies them (or nothing can).
|
||||
"""
|
||||
if (native := native_backend()) is not None:
|
||||
return native
|
||||
module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain))
|
||||
return importlib.import_module(module_path) if module_path else None
|
||||
|
||||
|
||||
def warn_ignored_platformio_options(consumed: Collection[str]) -> None:
|
||||
"""Warn for component-added platformio options a native build drops.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||
from esphome.components.ota import (
|
||||
BASE_OTA_SCHEMA,
|
||||
@@ -12,10 +13,7 @@ from esphome.components.ota import (
|
||||
)
|
||||
from esphome.components.zephyr import zephyr_data
|
||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||
from esphome.components.zephyr_ble_server import (
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
from esphome.components.zephyr_ble_server import DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||
from esphome.core import CoroPriority, coroutine_with_priority
|
||||
|
||||
@@ -253,8 +253,9 @@ template<typename... Ts> class APIRespondAction final : public Action<Ts...> {
|
||||
#endif
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes
|
||||
auto args = std::make_tuple(x...);
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes.
|
||||
// By reference: array args are non-copyable FixedVector views.
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
|
||||
bool success = this->success_.value(x...);
|
||||
@@ -308,7 +309,7 @@ template<typename... Ts> class APIUnregisterServiceCallAction final : public Act
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - same convention as APIRespondAction
|
||||
auto args = std::make_tuple(x...);
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
if (call_id != 0) {
|
||||
this->parent_->unregister_active_action_call(call_id);
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -101,10 +102,8 @@ BLERemoveBondAction = ble_client_ns.class_(
|
||||
)
|
||||
|
||||
CONF_PASSKEY = "passkey"
|
||||
CONF_ACCEPT = "accept"
|
||||
CONF_ON_PASSKEY_REQUEST = "on_passkey_request"
|
||||
CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification"
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
|
||||
CONF_AUTO_CONNECT = "auto_connect"
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -8,6 +8,7 @@ BYTE_ORDER_BIG = "big_endian"
|
||||
|
||||
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
|
||||
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
||||
CONF_ACCEPT = "accept"
|
||||
CONF_ADC_AVERAGING = "adc_averaging"
|
||||
CONF_ADC_TIME = "adc_time"
|
||||
CONF_ALLOWED_IPS = "allowed_ips"
|
||||
@@ -58,6 +59,7 @@ CONF_MAX_PACKET_SIZE = "max_packet_size"
|
||||
CONF_MODEM = "modem"
|
||||
CONF_MODULATION = "modulation"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
|
||||
CONF_ON_PACKET = "on_packet"
|
||||
CONF_ON_RECEIVE = "on_receive"
|
||||
CONF_ON_SCAN_END = "on_scan_end"
|
||||
|
||||
@@ -45,9 +45,8 @@ void CopyCover::control(const cover::CoverCall &call) {
|
||||
auto position = call.get_position();
|
||||
if (position.has_value())
|
||||
call2.set_position(*position);
|
||||
auto tilt2 = call.get_tilt();
|
||||
if (tilt2.has_value())
|
||||
call2.set_tilt(*tilt2);
|
||||
if (call.get_toggle().has_value())
|
||||
call2.set_command_toggle();
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,6 @@ void CopyLock::control(const lock::LockCall &call) {
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
void CopyLock::open_latch() { this->source_->open(); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -13,6 +13,7 @@ class CopyLock final : public lock::Lock, public Component {
|
||||
|
||||
protected:
|
||||
void control(const lock::LockCall &call) override;
|
||||
void open_latch() override;
|
||||
|
||||
lock::Lock *source_;
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ class DemoCover final : public cover::Cover, public Component {
|
||||
}
|
||||
if (call.get_stop()) {
|
||||
this->cancel_timeout(MOVE_TIMEOUT_ID);
|
||||
this->current_operation = cover::COVER_OPERATION_IDLE;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core, pins
|
||||
@@ -34,6 +35,8 @@ from esphome.types import ConfigType
|
||||
DOMAIN = "display"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
display_ns = cg.esphome_ns.namespace("display")
|
||||
Display = display_ns.class_("Display", cg.PollingComponent)
|
||||
DisplayBuffer = display_ns.class_("DisplayBuffer", Display)
|
||||
@@ -102,13 +105,21 @@ BASIC_DISPLAY_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _validate_test_card(config):
|
||||
def _validate_test_card(config: ConfigType) -> ConfigType:
|
||||
if (
|
||||
config.get(CONF_SHOW_TEST_CARD, False)
|
||||
and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN
|
||||
and (update_interval := config.get(CONF_UPDATE_INTERVAL)) is not None
|
||||
and update_interval.total_milliseconds == SCHEDULER_DONT_RUN
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"`{CONF_SHOW_TEST_CARD}: True` cannot be used with `{CONF_UPDATE_INTERVAL}: never` because this combination will not show a test_card."
|
||||
# Only a warning: something else can still update the display, for example
|
||||
# component.update, and the test card is drawn then.
|
||||
display_id = config.get(CONF_ID)
|
||||
_LOGGER.warning(
|
||||
"%s`%s: True` is used with `%s: never`, so the test card is only drawn "
|
||||
"when something updates the display (for example component.update)",
|
||||
f"{display_id.id}: " if display_id is not None and display_id.id else "",
|
||||
CONF_SHOW_TEST_CARD,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
|
||||
CONF_SKIP_CRC = "skip_crc"
|
||||
CONF_DEFAULT_OBIS = "default_obis"
|
||||
|
||||
DLMS_PARSER_VERSION = "2.2.0"
|
||||
DLMS_PARSER_VERSION = "2.2.2"
|
||||
|
||||
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
|
||||
DlmsMeterComponent = dlms_meter_component_ns.class_(
|
||||
|
||||
@@ -2389,7 +2389,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Optional(CONF_SOURCE): cv.git_ref,
|
||||
cv.Optional(CONF_SOURCE): cv.All(
|
||||
cv.string_strict, cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_REF): cv.string,
|
||||
cv.Optional(CONF_PATH): cv.string,
|
||||
}
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
#include "hal/rtc_io_hal.h"
|
||||
#endif
|
||||
|
||||
#ifndef SOC_GPIO_SUPPORT_RTC_INDEPENDENT
|
||||
#define SOC_GPIO_SUPPORT_RTC_INDEPENDENT 0 // NOLINT
|
||||
// some variants don't map rtcio to gpio 1:1
|
||||
#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32H21)
|
||||
#define RTCIO_NEED_MAPPING 1 // NOLINT
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32_VARIANT_ESP32) || (SOC_RTCIO_PIN_COUNT > 0 && defined(USE_GPIO_HOLD))
|
||||
#define ISR_PIN_USE_RTC 1 // NOLINT
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
@@ -53,10 +58,12 @@ struct ISRPinArg {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
bool hold;
|
||||
#endif
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
bool use_rtc;
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
int rtc_pin;
|
||||
#endif
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef USE_GPIO_HOLD
|
||||
@@ -69,17 +76,15 @@ static inline void refresh_hold(const ESP32InternalGPIOPin &pin) {
|
||||
static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) {
|
||||
if (!arg->hold)
|
||||
return;
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#if defined(ISR_PIN_USE_RTC) && defined(SOC_RTCIO_HOLD_SUPPORTED)
|
||||
if (arg->use_rtc) {
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
rtcio_hal_hold_disable(arg->rtc_pin);
|
||||
rtcio_hal_hold_enable(arg->rtc_pin);
|
||||
return;
|
||||
}
|
||||
#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
// GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them
|
||||
if (arg->pin < SOC_RTCIO_PIN_COUNT) {
|
||||
#else
|
||||
rtcio_hal_hold_disable(arg->pin);
|
||||
rtcio_hal_hold_enable(arg->pin);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -96,10 +101,12 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const {
|
||||
#ifdef USE_GPIO_HOLD
|
||||
arg->hold = this->get_hold();
|
||||
#endif
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef ISR_PIN_USE_RTC
|
||||
arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num());
|
||||
#ifdef RTCIO_NEED_MAPPING
|
||||
if (arg->use_rtc)
|
||||
arg->rtc_pin = rtc_io_number_get(this->get_pin_num());
|
||||
#endif
|
||||
#endif
|
||||
return ISRInternalGPIOPin((void *) arg);
|
||||
}
|
||||
@@ -251,7 +258,8 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
if (diff & gpio::FLAG_INPUT) {
|
||||
if (flags & gpio::FLAG_INPUT) {
|
||||
gpio_hal_input_enable(&GPIO_HAL, arg->pin);
|
||||
#if defined(USE_ESP32_VARIANT_ESP32)
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// Only ESP32 uses the RTC registers for pullup/pulldown
|
||||
if (arg->use_rtc) {
|
||||
if (flags & gpio::FLAG_PULLUP) {
|
||||
rtcio_hal_pullup_enable(arg->rtc_pin);
|
||||
|
||||
@@ -25,16 +25,86 @@ extern "C" {
|
||||
#include <freertos/FreeRTOSConfig.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#ifdef USE_ARDUINO
|
||||
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
|
||||
// Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE.
|
||||
extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming)
|
||||
#endif
|
||||
|
||||
#if !defined(CONFIG_BT_CONTROLLER_DISABLED) && \
|
||||
(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))
|
||||
// The ESP32-C3 and ESP32-S3 share one controller (lib_esp32c3_family, RivieraWaves link layer).
|
||||
#define USE_ESP32_BLE_TERMINATE_PATCH
|
||||
// Controller internals, reached through r_ip_funcs_p: a table btdm_controller_init() copies into RAM.
|
||||
extern "C" {
|
||||
extern void *r_ip_funcs_p;
|
||||
const char *btdm_controller_get_compile_version();
|
||||
uint8_t r_lld_con_terminate_max_evt_update(uint8_t link_id, uint8_t acked);
|
||||
uint8_t *r_lld_con_cntl_pkt_info_get();
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace esphome::esp32_ble {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "esp32_ble");
|
||||
|
||||
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
|
||||
// The controller ends its terminate procedure after 6 connection events without an acknowledgement and reports
|
||||
// the link closed. A peer using peripheral latency listens only every (latency + 1) events, so it can miss all
|
||||
// six LL_TERMINATE_IND: it keeps the link and searches for the central at full receive current until its own
|
||||
// supervision timeout. The Core spec keeps the procedure going until the acknowledgement or the supervision
|
||||
// timeout; this does that, by holding the controller's no-acknowledgement count below its limit. A peer that
|
||||
// never answers still ends the link at the supervision timeout.
|
||||
static constexpr size_t TERMINATE_MAX_EVT_SLOT = 0x7dc / sizeof(void *);
|
||||
static constexpr uint8_t TERMINATE_MAX_EVT = 5; // the controller gives up once its count exceeds this
|
||||
|
||||
static uint8_t IRAM_ATTR terminate_until_acked(uint8_t link_id, uint8_t acked) {
|
||||
// Per link: [2] terminate in progress, [3] connection events without an acknowledgement.
|
||||
uint8_t *info = r_lld_con_cntl_pkt_info_get() + link_id * 4;
|
||||
if (!acked && info[2] != 0 && info[3] >= TERMINATE_MAX_EVT)
|
||||
info[3] = TERMINATE_MAX_EVT - 1;
|
||||
return r_lld_con_terminate_max_evt_update(link_id, acked);
|
||||
}
|
||||
|
||||
// The controller builds whose r_lld_con_terminate_max_evt_update and r_lld_con_cntl_pkt_info_get the wrapper was
|
||||
// checked against. The table slot alone does not prove the pkt_info layout, so an unknown build is left alone.
|
||||
// btdm_controller_get_compile_version() is what ESP-IDF logs at init as "BT controller compile version [...]".
|
||||
// Recheck on every ESP-IDF bump: add the new build once both functions are confirmed unchanged, or drop the patch once
|
||||
// espressif/esp-idf#19189 is fixed.
|
||||
static const char *const TERMINATE_PATCH_CONTROLLER_BUILDS[] = {
|
||||
"51d9dfd", // ESP-IDF 5.5.5-5.5.6, Arduino 3.3.10-3.3.12
|
||||
"b7de11e", // ESP-IDF 5.5.4, Arduino 3.3.8-3.3.9: both functions identical to 51d9dfd's
|
||||
};
|
||||
|
||||
static void patch_terminate_until_acked() {
|
||||
auto **table = static_cast<void **>(r_ip_funcs_p);
|
||||
if (table == nullptr) {
|
||||
ESP_LOGW(TAG, "Controller layout not recognised; disconnects keep the controller's 6-event limit");
|
||||
return;
|
||||
}
|
||||
const char *build = btdm_controller_get_compile_version();
|
||||
bool known_build = false;
|
||||
for (const char *known : TERMINATE_PATCH_CONTROLLER_BUILDS)
|
||||
known_build = known_build || strcmp(build, known) == 0;
|
||||
if (!known_build) {
|
||||
// Expected after an ESP-IDF bump, and nothing a user can act on
|
||||
ESP_LOGD(TAG,
|
||||
"Controller build %s not checked for the terminate patch; disconnects keep the controller's 6-event limit",
|
||||
build);
|
||||
return;
|
||||
}
|
||||
if (table[TERMINATE_MAX_EVT_SLOT] == reinterpret_cast<void *>(&terminate_until_acked))
|
||||
return; // already patched: the controller stayed enabled across a setup
|
||||
if (table[TERMINATE_MAX_EVT_SLOT] != reinterpret_cast<void *>(&r_lld_con_terminate_max_evt_update)) {
|
||||
ESP_LOGW(TAG, "Controller build %s has an unexpected terminate handler; terminate patch skipped", build);
|
||||
return;
|
||||
}
|
||||
table[TERMINATE_MAX_EVT_SLOT] = reinterpret_cast<void *>(&terminate_until_acked);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_BT_CONTROLLER_DISABLED
|
||||
// Bringing up the remote BT controller issues synchronous RPCs to the
|
||||
// co-processor with 5 second response timeouts, and the default task watchdog
|
||||
@@ -223,6 +293,9 @@ bool ESP32BLE::ble_setup_() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
|
||||
patch_terminate_until_acked();
|
||||
#endif
|
||||
|
||||
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
|
||||
#else
|
||||
|
||||
@@ -373,7 +373,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
// Value initialized: a corrupt stream that back references the
|
||||
// window before it is filled then copies zeros, never stale memory.
|
||||
// Default placement, PSRAM first where present: the session lives for one
|
||||
// upload and keeps 6.0 KB of internal heap free while it runs
|
||||
// upload and keeps 4.9 KB of internal heap free while it runs
|
||||
this->inflate_ = RAMAllocator<InflateSession>().make_unique();
|
||||
if (this->inflate_ != nullptr) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE;
|
||||
|
||||
@@ -36,10 +36,7 @@
|
||||
* Altered for ESPHome: this is the raw deflate decoder from uzlib's
|
||||
* tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums,
|
||||
* runtime table builder and in-memory (non ring window) output path
|
||||
* removed, and the public names prefixed with ota_inflate. Symbols are
|
||||
* decoded from a 32 bit bit buffer through 9 bit lookup tables, with a
|
||||
* fast loop for when a whole symbol fits in both the input and the output,
|
||||
* and the output is written straight into the ring window.
|
||||
* removed, and the public names prefixed with ota_inflate.
|
||||
*/
|
||||
|
||||
#include "ota_esphome_inflate.h"
|
||||
@@ -52,6 +49,16 @@
|
||||
#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR
|
||||
#define TINF_DATA struct OtaInflateState
|
||||
#define TINF_TREE struct OtaInflateTree
|
||||
#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr)))
|
||||
|
||||
/* every output byte also goes into the ring window */
|
||||
#define TINF_PUT(d, c) \
|
||||
{ \
|
||||
*d->dest++ = c; \
|
||||
d->dict_ring[d->dict_idx++] = c; \
|
||||
if (d->dict_idx == d->dict_size) \
|
||||
d->dict_idx = 0; \
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- *
|
||||
* -- constant tables (upstream builds them at runtime) -- *
|
||||
@@ -75,52 +82,34 @@ static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4,
|
||||
* -- utility functions -- *
|
||||
* ----------------------- */
|
||||
|
||||
enum {
|
||||
LIT_FAST_BITS = OTA_INFLATE_LIT_FAST_BITS,
|
||||
DIST_FAST_BITS = OTA_INFLATE_DIST_FAST_BITS,
|
||||
/* a lookup table entry is (code length << SYM_BITS) | symbol; 0 marks a longer code */
|
||||
SYM_BITS = 9,
|
||||
SYM_MASK = (1 << SYM_BITS) - 1,
|
||||
/* input bytes one pass of the fast loop may take: three refills of up to 4 bytes */
|
||||
FAST_IN_MIN = 12,
|
||||
};
|
||||
|
||||
/* given an array of code lengths, build a tree and its lookup table */
|
||||
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num, unsigned int fast_bits) {
|
||||
const unsigned int fast_size = 1u << fast_bits;
|
||||
unsigned short offs[16], next[16];
|
||||
unsigned int i, sum, code;
|
||||
/* given an array of code lengths, build a tree */
|
||||
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) {
|
||||
unsigned short offs[16];
|
||||
unsigned int i, sum;
|
||||
|
||||
/* clear code length count table */
|
||||
for (i = 0; i < 16; ++i)
|
||||
t->table[i] = 0;
|
||||
|
||||
/* scan symbol lengths, and sum code length counts */
|
||||
for (i = 0; i < num; ++i)
|
||||
t->table[lengths[i]]++;
|
||||
|
||||
/* In the lengths array, 0 means unused code. So, t->table[0] now contains
|
||||
number of unused codes. But table's purpose is to contain # of codes of
|
||||
particular length, and there're 0 codes of length 0. */
|
||||
t->table[0] = 0;
|
||||
|
||||
for (sum = 0, code = 0, i = 0; i < 16; ++i) {
|
||||
/* compute offset table for distribution sort */
|
||||
for (sum = 0, i = 0; i < 16; ++i) {
|
||||
offs[i] = sum;
|
||||
sum += t->table[i];
|
||||
if (i > 0)
|
||||
code = (code + t->table[i - 1]) << 1;
|
||||
next[i] = code;
|
||||
}
|
||||
|
||||
for (i = 0; i < fast_size; ++i)
|
||||
t->fast[i] = 0;
|
||||
|
||||
/* create code->symbol translation table (symbols sorted by code) */
|
||||
for (i = 0; i < num; ++i) {
|
||||
unsigned int len = lengths[i];
|
||||
if (!len)
|
||||
continue;
|
||||
t->trans[offs[len]++] = i;
|
||||
if (len <= fast_bits) {
|
||||
/* codes arrive MSB first, the bit buffer is LSB first */
|
||||
unsigned int c = next[len]++, rev = 0, j;
|
||||
for (j = 0; j < len; ++j, c >>= 1)
|
||||
rev = (rev << 1) | (c & 1);
|
||||
for (j = rev; j < fast_size; j += 1u << len)
|
||||
t->fast[j] = (unsigned short) ((len << SYM_BITS) | i);
|
||||
}
|
||||
if (lengths[i])
|
||||
t->trans[offs[lengths[i]]++] = i;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,78 +117,89 @@ static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned
|
||||
* -- decode functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
/* top the bit buffer up from the input buffer, without blocking for more */
|
||||
static inline void tinf_refill(TINF_DATA *d) {
|
||||
while (d->bitcount <= 24 && d->source < d->source_limit) {
|
||||
d->tag |= (unsigned int) *d->source++ << d->bitcount;
|
||||
d->bitcount += 8;
|
||||
static unsigned char uzlib_get_byte(TINF_DATA *d) {
|
||||
/* If end of source buffer is not reached, return next byte from source
|
||||
buffer. */
|
||||
if (d->source < d->source_limit) {
|
||||
return *d->source++;
|
||||
}
|
||||
|
||||
/* Otherwise if there's callback and we haven't seen EOF yet, try to
|
||||
read next byte using it. (Note: the callback can also update ->source
|
||||
and ->source_limit). */
|
||||
if (!d->eof) {
|
||||
int val = d->source_read_cb(d);
|
||||
if (val >= 0) {
|
||||
return (unsigned char) val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion
|
||||
of the buffer), and it will be "sticky", i.e. further calls to this
|
||||
function will end up here too. */
|
||||
d->eof = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* make at least num (<= 16) bits available; past the end of input reads zeros and sets eof */
|
||||
static inline void tinf_need(TINF_DATA *d, unsigned int num) {
|
||||
while (d->bitcount < num) {
|
||||
int val = -1;
|
||||
if (d->source < d->source_limit) {
|
||||
val = *d->source++;
|
||||
} else if (!d->eof) {
|
||||
val = d->source_read_cb(d);
|
||||
}
|
||||
if (val < 0) {
|
||||
d->eof = true;
|
||||
val = 0;
|
||||
}
|
||||
d->tag |= (unsigned int) val << d->bitcount;
|
||||
d->bitcount += 8;
|
||||
}
|
||||
}
|
||||
/* get one bit from source stream */
|
||||
static int tinf_getbit(TINF_DATA *d) {
|
||||
unsigned int bit;
|
||||
|
||||
static inline unsigned int tinf_take(TINF_DATA *d, unsigned int num) {
|
||||
unsigned int val = d->tag & ((1u << num) - 1);
|
||||
d->tag >>= num;
|
||||
d->bitcount -= num;
|
||||
return val;
|
||||
/* check if tag is empty */
|
||||
if (!d->bitcount--) {
|
||||
/* load next tag */
|
||||
d->tag = uzlib_get_byte(d);
|
||||
d->bitcount = 7;
|
||||
}
|
||||
|
||||
/* shift bit out of tag */
|
||||
bit = d->tag & 0x01;
|
||||
d->tag >>= 1;
|
||||
|
||||
return bit;
|
||||
}
|
||||
|
||||
/* read a num bit value from a stream and add base */
|
||||
static inline unsigned int tinf_read_bits(TINF_DATA *d, unsigned int num, unsigned int base) {
|
||||
tinf_need(d, num);
|
||||
return tinf_take(d, num) + base;
|
||||
}
|
||||
static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) {
|
||||
unsigned int val = 0;
|
||||
|
||||
/* canonical decode of the code at the bottom of tag; len gets the code length.
|
||||
The trans index stays below the tree's symbol count, which trans always holds */
|
||||
static int tinf_decode_long(const TINF_TREE *t, unsigned int tag, unsigned int *len) {
|
||||
unsigned int code = 0, first = 0, index = 0, l;
|
||||
for (l = 1; l < 16; ++l, tag >>= 1) {
|
||||
unsigned int count = t->table[l];
|
||||
code |= tag & 1;
|
||||
if (code - first < count) {
|
||||
*len = l;
|
||||
return t->trans[index + code - first];
|
||||
}
|
||||
index += count;
|
||||
first = (first + count) << 1;
|
||||
code <<= 1;
|
||||
/* read num bits */
|
||||
if (num) {
|
||||
unsigned int limit = 1 << (num);
|
||||
unsigned int mask;
|
||||
|
||||
for (mask = 1; mask < limit; mask *= 2)
|
||||
if (tinf_getbit(d))
|
||||
val += mask;
|
||||
}
|
||||
return TINF_DATA_ERROR;
|
||||
|
||||
return val + base;
|
||||
}
|
||||
|
||||
/* decode a symbol, reading more input only while the code is not all in the bit
|
||||
buffer; bits above bitcount are zero, so a code no longer than bitcount is real */
|
||||
static int tinf_decode_symbol(TINF_DATA *d, const TINF_TREE *t) {
|
||||
tinf_refill(d);
|
||||
for (;;) {
|
||||
unsigned int len;
|
||||
int sym = tinf_decode_long(t, d->tag, &len);
|
||||
if (sym >= 0 && len <= d->bitcount) {
|
||||
tinf_take(d, len);
|
||||
return sym;
|
||||
}
|
||||
if (d->bitcount >= 15)
|
||||
/* given a data stream and a tree, decode a symbol */
|
||||
static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) {
|
||||
int sum = 0, cur = 0, len = 0;
|
||||
|
||||
/* get more bits while code value is above sum */
|
||||
do {
|
||||
cur = 2 * cur + tinf_getbit(d);
|
||||
|
||||
if (++len == TINF_ARRAY_SIZE(t->table)) {
|
||||
return TINF_DATA_ERROR;
|
||||
tinf_need(d, d->bitcount + 1);
|
||||
}
|
||||
|
||||
sum += t->table[len];
|
||||
cur -= t->table[len];
|
||||
|
||||
} while (cur >= 0);
|
||||
|
||||
sum += cur;
|
||||
if (sum < 0 || sum >= t->size) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
return t->trans[sum];
|
||||
}
|
||||
|
||||
/* given a data stream, decode dynamic trees from it */
|
||||
@@ -230,7 +230,7 @@ static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
}
|
||||
|
||||
/* build code length tree, temporarily use length tree */
|
||||
tinf_build_tree(lt, lengths, 19, LIT_FAST_BITS);
|
||||
tinf_build_tree(lt, lengths, 19);
|
||||
|
||||
/* decode code lengths for the dynamic trees */
|
||||
hlimit = hlit + hdist;
|
||||
@@ -282,8 +282,8 @@ static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
}
|
||||
|
||||
/* build dynamic trees */
|
||||
tinf_build_tree(lt, lengths, hlit, LIT_FAST_BITS);
|
||||
tinf_build_tree(dt, lengths + hlit, hdist, DIST_FAST_BITS);
|
||||
tinf_build_tree(lt, lengths, hlit);
|
||||
tinf_build_tree(dt, lengths + hlit, hdist);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
@@ -302,189 +302,84 @@ static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) {
|
||||
lengths[i] = 7;
|
||||
for (; i < 288; ++i)
|
||||
lengths[i] = 8;
|
||||
tinf_build_tree(lt, lengths, 288, LIT_FAST_BITS);
|
||||
tinf_build_tree(lt, lengths, 288);
|
||||
|
||||
for (i = 0; i < 32; ++i)
|
||||
lengths[i] = 5;
|
||||
tinf_build_tree(dt, lengths, 32, DIST_FAST_BITS);
|
||||
}
|
||||
|
||||
/* inflate with the bit buffer and both cursors in locals while the input holds a
|
||||
whole symbol; a match that runs past the end of the ring is left in curlen */
|
||||
static int tinf_inflate_fast(TINF_DATA *d, const TINF_TREE *lt, const TINF_TREE *dt) {
|
||||
const unsigned char *src = d->source;
|
||||
const unsigned char *const src_end = d->source_limit;
|
||||
unsigned char *const ring = d->dict_ring;
|
||||
unsigned char *const ring_end = ring + d->dict_size;
|
||||
unsigned char *out = d->dest;
|
||||
const uint16_t *const lfast = lt->fast;
|
||||
const uint16_t *const dfast = dt->fast;
|
||||
unsigned int tag = d->tag, bits = d->bitcount;
|
||||
int res = TINF_OK;
|
||||
|
||||
#define REFILL() \
|
||||
while (bits <= 24) { \
|
||||
tag |= (unsigned int) *src++ << bits; \
|
||||
bits += 8; \
|
||||
}
|
||||
#define DROP(n) (tag >>= (n), bits -= (n))
|
||||
|
||||
while (src_end - src >= FAST_IN_MIN && out < ring_end) {
|
||||
unsigned int e, len;
|
||||
int sym;
|
||||
|
||||
REFILL();
|
||||
e = lfast[tag & ((1u << LIT_FAST_BITS) - 1)];
|
||||
if (e) {
|
||||
len = e >> SYM_BITS;
|
||||
sym = e & SYM_MASK;
|
||||
} else {
|
||||
sym = tinf_decode_long(lt, tag, &len);
|
||||
if (sym < 0) {
|
||||
res = sym;
|
||||
break;
|
||||
}
|
||||
}
|
||||
DROP(len);
|
||||
|
||||
if (sym < 256) {
|
||||
*out++ = sym;
|
||||
continue;
|
||||
}
|
||||
if (sym == 256) {
|
||||
res = TINF_DONE;
|
||||
break;
|
||||
}
|
||||
sym -= 257;
|
||||
if (sym >= 29) {
|
||||
res = TINF_DATA_ERROR;
|
||||
break;
|
||||
}
|
||||
/* the lit/len code and its extra bits fit the refilled buffer together */
|
||||
unsigned int length = LENGTH_BASE[sym] + (tag & ((1u << LENGTH_BITS[sym]) - 1));
|
||||
DROP(LENGTH_BITS[sym]);
|
||||
|
||||
REFILL();
|
||||
e = dfast[tag & ((1u << DIST_FAST_BITS) - 1)];
|
||||
if (e) {
|
||||
len = e >> SYM_BITS;
|
||||
sym = e & SYM_MASK;
|
||||
} else {
|
||||
sym = tinf_decode_long(dt, tag, &len);
|
||||
}
|
||||
if (sym < 0 || sym >= 30) {
|
||||
res = TINF_DATA_ERROR;
|
||||
break;
|
||||
}
|
||||
DROP(len);
|
||||
REFILL();
|
||||
unsigned int offs = DIST_BASE[sym] + (tag & ((1u << DIST_BITS[sym]) - 1));
|
||||
DROP(DIST_BITS[sym]);
|
||||
if (offs > d->dict_size) {
|
||||
res = TINF_DICT_ERROR;
|
||||
break;
|
||||
}
|
||||
|
||||
/* wrap as an index: a pointer before the ring would be undefined */
|
||||
unsigned int at = out - ring;
|
||||
const unsigned char *from = ring + (at >= offs ? at - offs : at + d->dict_size - offs);
|
||||
unsigned int n = ring_end - out;
|
||||
if (n > length)
|
||||
n = length;
|
||||
length -= n;
|
||||
/* at most two runs: up to the end of the ring, then on from its start */
|
||||
while (n) {
|
||||
unsigned int run = ring_end - from;
|
||||
if (run > n)
|
||||
run = n;
|
||||
n -= run;
|
||||
do {
|
||||
*out++ = *from++;
|
||||
} while (--run);
|
||||
if (from == ring_end)
|
||||
from = ring;
|
||||
}
|
||||
if (length) {
|
||||
d->curlen = length;
|
||||
d->lz_off = from - ring;
|
||||
}
|
||||
}
|
||||
#undef REFILL
|
||||
#undef DROP
|
||||
|
||||
d->source = src;
|
||||
d->dest = out;
|
||||
d->tag = tag;
|
||||
d->bitcount = bits;
|
||||
return res;
|
||||
tinf_build_tree(dt, lengths, 32);
|
||||
}
|
||||
|
||||
/* ----------------------------- *
|
||||
* -- block inflate functions -- *
|
||||
* ----------------------------- */
|
||||
|
||||
/* given a stream and two trees, inflate until the ring is full or the block ends */
|
||||
/* given a stream and two trees, inflate next chunk of output (a byte or more) */
|
||||
static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
unsigned char *const ring_end = d->dict_ring + d->dict_size;
|
||||
while (d->dest < ring_end) {
|
||||
if (d->curlen != 0) {
|
||||
unsigned int n = ring_end - d->dest;
|
||||
if (n > d->curlen)
|
||||
n = d->curlen;
|
||||
d->curlen -= n;
|
||||
do {
|
||||
*d->dest++ = d->dict_ring[d->lz_off];
|
||||
if ((unsigned) ++d->lz_off == d->dict_size)
|
||||
d->lz_off = 0;
|
||||
} while (--n);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d->source_limit - d->source >= FAST_IN_MIN) {
|
||||
int res = tinf_inflate_fast(d, lt, dt);
|
||||
if (res != TINF_OK)
|
||||
return res;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d->curlen == 0) {
|
||||
unsigned int offs;
|
||||
int dist;
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
|
||||
if (d->eof)
|
||||
if (d->eof) {
|
||||
return TINF_DATA_ERROR;
|
||||
if (sym < 0)
|
||||
}
|
||||
|
||||
if (sym < 0) {
|
||||
return sym;
|
||||
}
|
||||
|
||||
/* literal byte */
|
||||
if (sym < 256) {
|
||||
*d->dest++ = sym;
|
||||
continue;
|
||||
TINF_PUT(d, sym);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* end of block */
|
||||
if (sym == 256)
|
||||
if (sym == 256) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
/* substring from sliding dictionary */
|
||||
sym -= 257;
|
||||
if (sym >= 29)
|
||||
if (sym >= 29) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from length code */
|
||||
d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]);
|
||||
|
||||
dist = tinf_decode_symbol(d, dt);
|
||||
if (dist < 0 || dist >= 30)
|
||||
if (dist < 0 || dist >= 30) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from distance code */
|
||||
offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]);
|
||||
if (offs > d->dict_size)
|
||||
return TINF_DICT_ERROR;
|
||||
|
||||
d->lz_off = (int) (d->dest - d->dict_ring) - (int) offs;
|
||||
if (d->lz_off < 0)
|
||||
/* calculate and validate actual LZ offset to use */
|
||||
if (offs > d->dict_size) {
|
||||
return TINF_DICT_ERROR;
|
||||
}
|
||||
/* Note: we don't try to catch offset which points to not yet filled
|
||||
part of the dictionary here. Doing so would require keeping another
|
||||
variable to track "filled in" size of the dictionary. Appearance of
|
||||
such an offset cannot lead to accessing memory outside of the
|
||||
dictionary buffer, and clients which don't want to leak unrelated
|
||||
information, should explicitly initialize dictionary buffer passed
|
||||
to uzlib. */
|
||||
|
||||
d->lz_off = d->dict_idx - offs;
|
||||
if (d->lz_off < 0) {
|
||||
d->lz_off += d->dict_size;
|
||||
}
|
||||
}
|
||||
|
||||
/* copy next byte from dict substring */
|
||||
TINF_PUT(d, d->dict_ring[d->lz_off]);
|
||||
if ((unsigned) ++d->lz_off == d->dict_size) {
|
||||
d->lz_off = 0;
|
||||
}
|
||||
d->curlen--;
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
@@ -493,22 +388,30 @@ static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int length, invlength;
|
||||
|
||||
/* the block starts on a byte boundary */
|
||||
tinf_take(d, d->bitcount & 7);
|
||||
length = tinf_read_bits(d, 16, 0);
|
||||
invlength = tinf_read_bits(d, 16, 0);
|
||||
/* get length */
|
||||
length = uzlib_get_byte(d);
|
||||
length += 256 * uzlib_get_byte(d);
|
||||
/* get one's complement of length */
|
||||
invlength = uzlib_get_byte(d);
|
||||
invlength += 256 * uzlib_get_byte(d);
|
||||
/* check length */
|
||||
if (length != (~invlength & 0x0000ffff))
|
||||
return TINF_DATA_ERROR;
|
||||
|
||||
/* increment length to properly return TINF_DONE below, without
|
||||
producing data at the same time */
|
||||
d->curlen = length + 1;
|
||||
|
||||
/* make sure we start next block on a byte boundary */
|
||||
d->bitcount = 0;
|
||||
}
|
||||
|
||||
if (--d->curlen == 0)
|
||||
if (--d->curlen == 0) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
/* read first: a read that needs input may flush the output up to dest */
|
||||
unsigned char c = tinf_read_bits(d, 8, 0);
|
||||
*d->dest++ = c;
|
||||
unsigned char c = uzlib_get_byte(d);
|
||||
TINF_PUT(d, c);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
@@ -516,10 +419,10 @@ static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
|
||||
* -- public functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
/* initialize decompression structure */
|
||||
void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) {
|
||||
d->source = NULL;
|
||||
d->source_limit = NULL;
|
||||
d->dest = dict;
|
||||
d->tag = 0;
|
||||
d->eof = 0;
|
||||
d->bitcount = 0;
|
||||
@@ -528,56 +431,68 @@ void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len)
|
||||
d->btype = -1;
|
||||
d->dict_size = dict_len;
|
||||
d->dict_ring = dict;
|
||||
d->dict_idx = 0;
|
||||
d->curlen = 0;
|
||||
d->ltree.trans = d->ltrans;
|
||||
d->ltree.fast = d->lfast;
|
||||
d->ltree.size = TINF_ARRAY_SIZE(d->ltrans);
|
||||
d->dtree.trans = d->dtrans;
|
||||
d->dtree.fast = d->dfast;
|
||||
d->dtree.size = TINF_ARRAY_SIZE(d->dtrans);
|
||||
}
|
||||
|
||||
/* inflate next output bytes from compressed stream */
|
||||
int ota_inflate(TINF_DATA *d) {
|
||||
/* the caller has taken the previous ringful */
|
||||
if (d->dest == d->dict_ring + d->dict_size)
|
||||
d->dest = d->dict_ring;
|
||||
|
||||
do {
|
||||
int res;
|
||||
|
||||
/* start a new block */
|
||||
if (d->btype == -1) {
|
||||
int old_btype;
|
||||
next_blk:
|
||||
old_btype = d->btype;
|
||||
d->bfinal = tinf_read_bits(d, 1, 0);
|
||||
/* read final block flag */
|
||||
d->bfinal = tinf_getbit(d);
|
||||
/* read block type (2 bits) */
|
||||
d->btype = tinf_read_bits(d, 2, 0);
|
||||
|
||||
if (d->btype == 1 && old_btype != 1) {
|
||||
/* build fixed huffman trees */
|
||||
tinf_build_fixed_trees(&d->ltree, &d->dtree);
|
||||
} else if (d->btype == 2) {
|
||||
/* decode trees from stream */
|
||||
res = tinf_decode_trees(d, &d->ltree, &d->dtree);
|
||||
if (res != TINF_OK)
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process current block */
|
||||
switch (d->btype) {
|
||||
case 0:
|
||||
/* decompress uncompressed block */
|
||||
res = tinf_inflate_uncompressed_block(d);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
/* decompress block with fixed/dynamic huffman trees */
|
||||
/* trees were decoded previously, so it's the same routine for both */
|
||||
res = tinf_inflate_block_data(d, &d->ltree, &d->dtree);
|
||||
break;
|
||||
default:
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (res == TINF_DONE && !d->bfinal)
|
||||
if (res == TINF_DONE && !d->bfinal) {
|
||||
/* the block has ended (without producing more data), but we
|
||||
can't return without data, so start procesing next block */
|
||||
goto next_blk;
|
||||
}
|
||||
|
||||
if (res != TINF_OK)
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
|
||||
} while (d->dest < d->dict_ring + d->dict_size);
|
||||
} while (d->dest < d->dest_limit);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
@@ -16,14 +16,10 @@ enum OtaInflateResult {
|
||||
OTA_INFLATE_DICT_ERROR = -5,
|
||||
};
|
||||
|
||||
/* Code bits each lookup table resolves; longer codes take the canonical decode.
|
||||
The distance alphabet has 30 symbols, so its table is kept small */
|
||||
enum { OTA_INFLATE_LIT_FAST_BITS = 9, OTA_INFLATE_DIST_FAST_BITS = 6 };
|
||||
|
||||
struct OtaInflateTree {
|
||||
uint16_t table[16]; /* table of code length counts */
|
||||
uint16_t *fast; /* (len << 9) | symbol by the next input bits, 0 = longer code */
|
||||
uint16_t *trans; /* code -> symbol translation table */
|
||||
uint16_t *trans; /* code -> symbol translation table, size entries */
|
||||
uint16_t size;
|
||||
};
|
||||
|
||||
struct OtaInflateState {
|
||||
@@ -33,8 +29,9 @@ struct OtaInflateState {
|
||||
/* Called when source is exhausted; returns the next byte or -1 at EOF.
|
||||
It may refill source/source_limit for buffered operation. */
|
||||
int (*source_read_cb)(struct OtaInflateState *d);
|
||||
/* Output cursor in dict_ring: each call fills the ring from here */
|
||||
/* Output cursor and one past the end of the output buffer */
|
||||
unsigned char *dest;
|
||||
unsigned char *dest_limit;
|
||||
/* Ring window holding the last dict_size output bytes for back references */
|
||||
unsigned char *dict_ring;
|
||||
|
||||
@@ -42,8 +39,9 @@ struct OtaInflateState {
|
||||
unsigned int curlen;
|
||||
int lz_off;
|
||||
unsigned int dict_size;
|
||||
unsigned int dict_idx;
|
||||
|
||||
/* btype is -1 between blocks; bitcount is the number of valid bits in tag (<= 32) */
|
||||
/* One word: btype is -1 between blocks, bitcount never exceeds 7 */
|
||||
int8_t btype;
|
||||
uint8_t bfinal;
|
||||
uint8_t bitcount;
|
||||
@@ -53,15 +51,13 @@ struct OtaInflateState {
|
||||
struct OtaInflateTree dtree; /* dynamic distance tree */
|
||||
uint16_t ltrans[288];
|
||||
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
|
||||
uint16_t lfast[1 << OTA_INFLATE_LIT_FAST_BITS];
|
||||
uint16_t dfast[1 << OTA_INFLATE_DIST_FAST_BITS];
|
||||
};
|
||||
|
||||
/* dict must cover the encoder's window (its largest back reference) */
|
||||
void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len);
|
||||
/* Inflates into dict until it is full (OK) or the stream ends (DONE); the output
|
||||
is dict up to dest. The next call after OK starts dict over, so take its
|
||||
contents first */
|
||||
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may
|
||||
alias dict only if dest_limit - dest == dict_len and dest is reset to dict
|
||||
exactly when a call returns OK, so the ring index and dest stay in lockstep */
|
||||
int ota_inflate(struct OtaInflateState *d);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -65,15 +65,16 @@ ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t ima
|
||||
};
|
||||
|
||||
int res;
|
||||
session.flushed = 0;
|
||||
do {
|
||||
// The ring index wrapped to 0 exactly when the window filled
|
||||
session.dest = session.window;
|
||||
session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE;
|
||||
session.flushed = 0;
|
||||
res = ota_inflate(&session);
|
||||
// A stored block keeps emitting zeros after a failed read, hence eof
|
||||
if (res < 0 || session.eof)
|
||||
break;
|
||||
session.error = this->inflate_flush_(session);
|
||||
// A full window is all in flash; the next call starts it over
|
||||
session.flushed = 0;
|
||||
} while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK);
|
||||
|
||||
// Transport and flash failures are logged where they happen
|
||||
|
||||
@@ -289,8 +289,12 @@ using socket_t = int;
|
||||
#elif defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#if TARGET_OS_OSX
|
||||
// The precompiled header brings in ArduinoJson.h, whose `using namespace ArduinoJson` makes MacTypes.h's
|
||||
// global Ptr ambiguous with ArduinoJson's Ptr class; give the macOS one another name here
|
||||
#define Ptr esphome_mactypes_Ptr
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/Security.h>
|
||||
#undef Ptr
|
||||
#endif // TARGET_OS_OSX
|
||||
#endif // _WIN32
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "ld2410s.h"
|
||||
#include "ld2410s_number.h"
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
@@ -40,6 +39,11 @@ static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
|
||||
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
|
||||
#ifdef USE_NUMBER
|
||||
// Module units to number units: 0.7 m gates, seconds, tenths of a hertz (number.py uses the same)
|
||||
static constexpr float PARAMETER_SCALES[] PROGMEM = {0.7f, 0.7f, 1.0f, 0.1f, 0.1f, 1.0f};
|
||||
static_assert(sizeof(PARAMETER_SCALES) / sizeof(PARAMETER_SCALES[0]) == PARAMETER_COUNT, "one scale per parameter");
|
||||
#endif
|
||||
#endif
|
||||
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
|
||||
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
|
||||
@@ -136,6 +140,27 @@ bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
|
||||
this->parameters_dirty_ = true; // written by loop() once no sequence is running
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_NUMBER
|
||||
void LD2410SComponent::control_number(number::Number *number, float value) {
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
if (this->numbers_[i] != number)
|
||||
continue;
|
||||
// Show the step the module got, not a value between two steps
|
||||
const uint8_t raw = lroundf(value / PARAMETER_SCALES[i]); // the schema keeps every parameter within a byte
|
||||
if (this->set_parameter(static_cast<Parameter>(i), raw))
|
||||
number->publish_state(raw * PARAMETER_SCALES[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void LD2410SComponent::control_response_speed(select::Select *select, size_t index) {
|
||||
if (this->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
|
||||
select->publish_state(index);
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
void LD2410SComponent::restart_init_(uint32_t at) {
|
||||
@@ -502,7 +527,7 @@ void LD2410SComponent::publish_parameters_() {
|
||||
#ifdef USE_NUMBER
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
if (this->numbers_[i] != nullptr) {
|
||||
this->numbers_[i]->publish_raw(this->parameters_[i]);
|
||||
this->numbers_[i]->publish_state(this->parameters_[i] * PARAMETER_SCALES[i]);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
#include "esphome/components/number/number.h"
|
||||
#endif
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
#include "esphome/components/select/select.h"
|
||||
#endif
|
||||
@@ -39,10 +42,6 @@ static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAM
|
||||
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
|
||||
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
|
||||
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
class LD2410SNumber;
|
||||
#endif
|
||||
|
||||
class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
SUB_BINARY_SENSOR(target)
|
||||
@@ -67,9 +66,14 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
bool set_parameter(Parameter parameter, uint8_t value);
|
||||
#ifdef USE_NUMBER
|
||||
void set_number(Parameter parameter, LD2410SNumber *number) {
|
||||
void set_number(Parameter parameter, number::Number *number) {
|
||||
this->numbers_[static_cast<uint8_t>(parameter)] = number;
|
||||
}
|
||||
/// Writes the number's parameter and publishes the step the module got.
|
||||
void control_number(number::Number *number, float value);
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
void control_response_speed(select::Select *select, size_t index);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -109,7 +113,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
#endif
|
||||
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
|
||||
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
|
||||
#endif
|
||||
|
||||
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
|
||||
#include "ld2410s.h"
|
||||
#include "esphome/components/number/number.h"
|
||||
|
||||
namespace esphome::ld2410s {
|
||||
|
||||
/// Edits one common parameter; scale converts the module's units to the number's.
|
||||
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
|
||||
public:
|
||||
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
|
||||
|
||||
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
|
||||
|
||||
protected:
|
||||
void control(float value) override {
|
||||
// Show the step the module got, not a value between two steps
|
||||
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
|
||||
if (this->parent_->set_parameter(this->parameter_, raw))
|
||||
this->publish_raw(raw);
|
||||
}
|
||||
|
||||
const Parameter parameter_;
|
||||
const float scale_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
|
||||
@@ -1,21 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
|
||||
#include "ld2410s.h"
|
||||
#include "esphome/components/select/select.h"
|
||||
|
||||
namespace esphome::ld2410s {
|
||||
|
||||
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
|
||||
protected:
|
||||
void control(size_t index) override {
|
||||
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
|
||||
this->publish_state(index);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
|
||||
@@ -16,7 +16,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
@@ -26,9 +26,8 @@ CONF_MIN_DISTANCE = "min_distance"
|
||||
CONF_NO_DELAY = "no_delay"
|
||||
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
|
||||
|
||||
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
|
||||
|
||||
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
|
||||
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz;
|
||||
# PARAMETER_SCALES in ld2410s.cpp converts with the same factors
|
||||
GATE_WIDTH_M = 0.7
|
||||
FREQUENCY_SCALE = 0.1
|
||||
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
|
||||
@@ -37,7 +36,7 @@ _FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
|
||||
|
||||
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
|
||||
return number.number_schema(
|
||||
LD2410SNumber,
|
||||
number.ApplyNumber,
|
||||
device_class=device_class,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
unit_of_measurement=unit,
|
||||
@@ -72,22 +71,19 @@ async def to_code(config: ConfigType) -> None:
|
||||
await numbers(
|
||||
CONF_MAX_DISTANCE,
|
||||
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
|
||||
Parameter.PARAMETER_MAX_DISTANCE,
|
||||
GATE_WIDTH_M,
|
||||
apply="control_number(entity, value)",
|
||||
**_DISTANCE_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_MIN_DISTANCE,
|
||||
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
|
||||
Parameter.PARAMETER_MIN_DISTANCE,
|
||||
GATE_WIDTH_M,
|
||||
apply="control_number(entity, value)",
|
||||
**_DISTANCE_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_NO_DELAY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
|
||||
Parameter.PARAMETER_NO_DELAY,
|
||||
1,
|
||||
apply="control_number(entity, value)",
|
||||
min_value=1,
|
||||
max_value=120,
|
||||
step=1,
|
||||
@@ -95,14 +91,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
await numbers(
|
||||
CONF_STATUS_REPORTING_FREQUENCY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
|
||||
Parameter.PARAMETER_STATUS_FREQUENCY,
|
||||
FREQUENCY_SCALE,
|
||||
apply="control_number(entity, value)",
|
||||
**_FREQUENCY_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_DISTANCE_REPORTING_FREQUENCY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
|
||||
Parameter.PARAMETER_DISTANCE_FREQUENCY,
|
||||
FREQUENCY_SCALE,
|
||||
apply="control_number(entity, value)",
|
||||
**_FREQUENCY_RANGE,
|
||||
)
|
||||
@@ -4,18 +4,16 @@ import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
CONF_RESPONSE_SPEED = "response_speed"
|
||||
|
||||
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
|
||||
LD2410SSelect,
|
||||
select.ApplySelect,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon="mdi:speedometer",
|
||||
),
|
||||
@@ -27,5 +25,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||
selects = select.sub_selects(config, parent=hub)
|
||||
await selects(
|
||||
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
|
||||
CONF_RESPONSE_SPEED,
|
||||
hub.set_response_speed_select,
|
||||
options=["Normal", "Fast"],
|
||||
apply="control_response_speed(entity, index)",
|
||||
)
|
||||
@@ -138,6 +138,11 @@ static uint8_t calc_checksum(const void *data, size_t size) {
|
||||
return xor_bytes(static_cast<const uint8_t *>(data), size);
|
||||
}
|
||||
|
||||
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
|
||||
static uint16_t system_mode_for(uint8_t mode) {
|
||||
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
|
||||
}
|
||||
|
||||
static int32_t get_firmware_int(const char *version_string) {
|
||||
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
|
||||
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
|
||||
@@ -207,13 +212,10 @@ void LD2420Component::setup() {
|
||||
|
||||
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
this->set_operating_mode(OP_SIMPLE_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
} else {
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
|
||||
}
|
||||
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
|
||||
this->set_mode_(system_mode_for(this->current_operating_mode));
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
@@ -244,7 +246,9 @@ void LD2420Component::apply_config_action() {
|
||||
#endif
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
|
||||
if (this->current_operating_mode == OP_CALIBRATE_MODE)
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
}
|
||||
|
||||
void LD2420Component::factory_reset_action() {
|
||||
@@ -356,6 +360,22 @@ void LD2420Component::set_operating_mode(const char *state) {
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::change_operating_mode(OpMode mode) {
|
||||
this->set_operating_mode(mode);
|
||||
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
|
||||
if (system_mode == this->get_mode_())
|
||||
return;
|
||||
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
|
||||
this->set_system_mode(system_mode);
|
||||
this->set_config_mode(false);
|
||||
}
|
||||
if (this->get_mode_() != system_mode) {
|
||||
// The module kept its reporting mode; show the mode that matches it
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::set_operating_mode(OpMode mode) {
|
||||
// Older firmware only supports simple mode
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
|
||||
@@ -119,6 +119,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void handle_cmd_error(uint16_t error);
|
||||
void set_operating_mode(const char *state);
|
||||
void set_operating_mode(OpMode mode);
|
||||
void change_operating_mode(OpMode mode);
|
||||
void auto_calibrate_sensitivity();
|
||||
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
|
||||
uint8_t set_config_mode(bool enable);
|
||||
|
||||
@@ -30,6 +30,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
CONF_OPERATING_MODE,
|
||||
hub.set_operating_mode_select,
|
||||
options=CONF_SELECTS,
|
||||
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
publish="before",
|
||||
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
)
|
||||
@@ -607,9 +607,10 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
# esp_rom_printf instead of pulling ~1.2KB esp_rom_vprintf into IRAM.
|
||||
# S2/C2 have a ROM vprintf, so IDF forces CONSTRAINED_ENV_SAFE=1 and
|
||||
# cache-off DRAM logs would reach the flash-resident wrapper; keep V1.
|
||||
if idf_version() >= cv.Version(6, 1, 0) and get_esp32_variant() not in (
|
||||
VARIANT_ESP32C2,
|
||||
VARIANT_ESP32S2,
|
||||
if (
|
||||
CORE.using_toolchain_esp_idf
|
||||
and idf_version() >= cv.Version(6, 1, 0)
|
||||
and get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32S2)
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
|
||||
|
||||
@@ -339,8 +339,8 @@ def final_validation(config_list):
|
||||
|
||||
def lv_conf_path_flag(lv_conf_h_file: Path) -> str:
|
||||
"""The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell
|
||||
lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments
|
||||
(ESP-IDF, Zephyr) the build flags pass through."""
|
||||
lexers (PlatformIO, host, ESP8266, and ESP-IDF before it quotes each
|
||||
token for CMake) and the raw quoted CMake argument (Zephyr)."""
|
||||
# forward slashes for Windows, then escape what shlex or a quoted CMake
|
||||
# argument would not pass through literally
|
||||
path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix())
|
||||
@@ -414,7 +414,8 @@ async def to_code(configs):
|
||||
"LV_COLOR_CHROMA_KEY",
|
||||
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
|
||||
)
|
||||
cg.add_build_flag("-Isrc")
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-Isrc")
|
||||
|
||||
cg.add_global(lvgl_ns.using)
|
||||
for font in df.get_esphome_fonts_used():
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@@ -53,16 +54,25 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
|
||||
|
||||
class DataCallbackManager : public DataCallbackManagerBase {
|
||||
public:
|
||||
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
|
||||
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
|
||||
: stream_info_(stream_info), mute_state_(mute_state) {}
|
||||
|
||||
using DataCallbackManagerBase::call;
|
||||
|
||||
// Remove before 2027.5.0, along with this class (use a plain CallbackManager)
|
||||
ESPDEPRECATED("Use Microphone::publish_audio_() instead. Removed in 2027.5.0", "2026.11.0")
|
||||
void call(const std::vector<uint8_t> &data) { this->call(AudioCapture{data, *this->stream_info_, 0}); }
|
||||
void call(const std::vector<uint8_t> &data) {
|
||||
if (*this->mute_state_) {
|
||||
const std::vector<uint8_t> zeros(data.size(), 0);
|
||||
this->call(AudioCapture{zeros, *this->stream_info_, 0});
|
||||
} else {
|
||||
this->call(AudioCapture{data, *this->stream_info_, 0});
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
const audio::AudioStreamInfo *stream_info_;
|
||||
const bool *mute_state_;
|
||||
};
|
||||
|
||||
class Microphone {
|
||||
@@ -72,15 +82,7 @@ class Microphone {
|
||||
|
||||
/// Callbacks take (const AudioCapture &) and run on the microphone's task.
|
||||
template<typename F> void add_data_callback(F &&data_callback) {
|
||||
this->data_callbacks_.add(
|
||||
[this, callback = as_capture_callback(std::forward<F>(data_callback))](const AudioCapture &capture) {
|
||||
if (this->mute_state_) {
|
||||
const std::vector<uint8_t> zeros(capture.data.size(), 0);
|
||||
callback(AudioCapture{zeros, capture.stream_info, capture.capture_time_us});
|
||||
} else {
|
||||
callback(capture);
|
||||
}
|
||||
});
|
||||
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
|
||||
}
|
||||
|
||||
bool is_running() const { return this->state_ == STATE_RUNNING; }
|
||||
@@ -92,8 +94,11 @@ class Microphone {
|
||||
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
|
||||
|
||||
protected:
|
||||
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
|
||||
int64_t capture_time_us = 0) {
|
||||
/// While muted, data is zeroed first, so it must be a buffer the platform owns.
|
||||
void publish_audio_(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us = 0) {
|
||||
if (this->mute_state_) {
|
||||
std::memset(data.data(), 0, data.size());
|
||||
}
|
||||
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
|
||||
}
|
||||
|
||||
@@ -105,7 +110,7 @@ class Microphone {
|
||||
audio::AudioStreamInfo audio_stream_info_;
|
||||
|
||||
// Make private when the deprecated call(data) is removed in 2027.5.0
|
||||
DataCallbackManager data_callbacks_{&this->audio_stream_info_};
|
||||
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
|
||||
};
|
||||
|
||||
} // namespace esphome::microphone
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from esphome import pins
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
from esphome.components.const import CONF_ROLE
|
||||
@@ -170,6 +170,37 @@ def reject_broadcast_options_for_unicast(
|
||||
return validator
|
||||
|
||||
|
||||
# Raw PDU; valid only during the automation.
|
||||
PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
_request_on_request_slot = cg.slot_counter("MODBUS_ON_REQUEST_COUNT")
|
||||
|
||||
|
||||
async def register_on_request_automation(hub: MockObj, config: ConfigType) -> None:
|
||||
"""Run an automation for every request the hub reads (server) or sends (client)."""
|
||||
_request_on_request_slot(str(hub))
|
||||
await automation.build_callback_automation(
|
||||
hub,
|
||||
"add_on_request_callback",
|
||||
[(cg.uint8, "address"), (PDU_SPAN, "request")],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
_request_on_response_slot = cg.slot_counter("MODBUS_ON_RESPONSE_COUNT")
|
||||
|
||||
|
||||
async def register_on_response_automation(hub: MockObj, config: ConfigType) -> None:
|
||||
"""Run an automation for every response the hub gets (client), or sends or sees from another device (server)."""
|
||||
_request_on_response_slot(str(hub))
|
||||
await automation.build_callback_automation(
|
||||
hub,
|
||||
"add_on_response_callback",
|
||||
[(cg.uint8, "address"), (PDU_SPAN, "request"), (PDU_SPAN, "response")],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def reject_inapplicable_command_options(
|
||||
pdu_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
|
||||
@@ -319,13 +319,22 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
|
||||
|
||||
// Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends
|
||||
// a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked.
|
||||
// This requires copying the frame data to a local buffer beforehand.
|
||||
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
|
||||
uint16_t data_len = frame_length - 2 - data_offset;
|
||||
uint8_t data_buffer[MAX_FRAME_SIZE] = {};
|
||||
std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
|
||||
std::span<const uint8_t> data(data_buffer, data_len);
|
||||
// This requires copying the frame data out of the rx buffer beforehand.
|
||||
const uint16_t pdu_len = frame_length - 3; // less the address byte and the CRC
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
uint8_t *pdu_buffer = this->request_;
|
||||
this->request_len_ = static_cast<uint8_t>(pdu_len);
|
||||
#else
|
||||
uint8_t pdu_buffer[MAX_PDU_SIZE];
|
||||
#endif
|
||||
std::memcpy(pdu_buffer, this->rx_buffer_.data() + 1, pdu_len);
|
||||
const std::span<const uint8_t> pdu(pdu_buffer, pdu_len);
|
||||
const std::span<const uint8_t> data = pdu.subspan(1); // after the function code
|
||||
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
// Before a device here answers, so the monitor sees the bus in order.
|
||||
this->request_callback_.call(address, pdu);
|
||||
#endif
|
||||
|
||||
if (address == BROADCAST_ADDRESS) {
|
||||
// Keep the unicast response buffers out of the broadcast call chain.
|
||||
@@ -389,15 +398,27 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
|
||||
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address,
|
||||
us_since_send(this->last_modbus_byte_, this->last_send_));
|
||||
}
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// After the terminal, so the handler can queue the same request again
|
||||
this->response_callback_.call(address, cmd->frame.pdu(), pdu);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t>) {
|
||||
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
if (this->find_device_(address) != nullptr) {
|
||||
ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
|
||||
}
|
||||
|
||||
if (this->expecting_peer_response_ == address) {
|
||||
ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address);
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
if (helpers::pdu_function_code(pdu) == this->request_[0]) {
|
||||
this->response_callback_.call(address, std::span<const uint8_t>(this->request_, this->request_len_), pdu);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Response from peer %" PRIu8 " has function 0x%02X, request had 0x%02X; not paired", address,
|
||||
pdu[0], this->request_[0]);
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address);
|
||||
}
|
||||
@@ -848,9 +869,13 @@ void ModbusClientHub::send_next_frame_() {
|
||||
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
|
||||
cmd->complete_broadcast();
|
||||
this->sweep_needed_ = true;
|
||||
return;
|
||||
} else {
|
||||
this->waiting_for_response_ = true;
|
||||
}
|
||||
this->waiting_for_response_ = true;
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
// After complete_broadcast(), so a broadcast queued from the handler is not merged into this one.
|
||||
this->request_callback_.call(cmd->frame.address(), cmd->frame.pdu());
|
||||
#endif
|
||||
}
|
||||
|
||||
void ModbusClientHub::dump_config() {
|
||||
@@ -1218,7 +1243,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
|
||||
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
|
||||
this->deferred_payload_len_ - 1);
|
||||
this->deferred_payload_len_ = 0;
|
||||
if (!this->send_frame_(frame)) {
|
||||
if (!this->send_reply_(frame)) {
|
||||
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
|
||||
}
|
||||
});
|
||||
@@ -1226,11 +1251,22 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
|
||||
}
|
||||
|
||||
ModbusFrame frame(payload[0], payload + 1, len - 1);
|
||||
if (!this->send_frame_(frame)) {
|
||||
if (!this->send_reply_(frame)) {
|
||||
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
|
||||
}
|
||||
}
|
||||
|
||||
bool ModbusServerHub::send_reply_(const ModbusFrame &frame) {
|
||||
if (!this->send_frame_(frame))
|
||||
return false;
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// Only once on the wire: a reply that is dropped is not reported.
|
||||
this->response_callback_.call(frame.address(), std::span<const uint8_t>(this->request_, this->request_len_),
|
||||
frame.pdu());
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
|
||||
size_t bytes = this->rx_buffer_.size();
|
||||
if (bytes_to_clear > 0 && bytes >= bytes_to_clear)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include "esphome/components/modbus/modbus_definitions.h"
|
||||
@@ -13,6 +14,7 @@
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace esphome::modbus {
|
||||
|
||||
@@ -58,6 +60,19 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
virtual bool tx_blocked();
|
||||
|
||||
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
/// Every request read (server, before a device answers) or sent (client, retries included); PDU valid in the call.
|
||||
template<typename F> void add_on_request_callback(F &&callback) {
|
||||
this->request_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
/// Every response received (client) or sent or seen (server); PDUs valid during the call. A server pairs a peer's
|
||||
/// reply with the last request by address and function code only.
|
||||
template<typename F> void add_on_response_callback(F &&callback) {
|
||||
this->response_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
void receive_bytes_();
|
||||
@@ -86,6 +101,13 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
GPIOPin *flow_control_pin_{nullptr};
|
||||
|
||||
std::vector<uint8_t> rx_buffer_;
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
StaticCallbackManager<MODBUS_ON_REQUEST_COUNT, void(uint8_t, std::span<const uint8_t>)> request_callback_;
|
||||
#endif
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
StaticCallbackManager<MODBUS_ON_RESPONSE_COUNT, void(uint8_t, std::span<const uint8_t>, std::span<const uint8_t>)>
|
||||
response_callback_;
|
||||
#endif
|
||||
};
|
||||
|
||||
class ModbusClientDevice;
|
||||
@@ -360,11 +382,18 @@ class ModbusServerHub : public Modbus {
|
||||
uint16_t number_of_registers, const RegisterValues ®isters,
|
||||
std::span<uint8_t> response_buffer, uint16_t &response_len);
|
||||
void send_raw_(const uint8_t *payload, uint16_t len);
|
||||
// Every reply of this hub goes on the wire here, so on_response sees each one once, exceptions included.
|
||||
bool send_reply_(const ModbusFrame &frame);
|
||||
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
|
||||
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
|
||||
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
|
||||
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
|
||||
uint8_t expecting_peer_response_{0};
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// The last request, for on_response with its reply from a device here or another one.
|
||||
uint8_t request_len_{0};
|
||||
uint8_t request_[MAX_PDU_SIZE];
|
||||
#endif
|
||||
std::vector<ModbusServerDevice *> devices_;
|
||||
|
||||
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
|
||||
|
||||
@@ -72,7 +72,7 @@ ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
|
||||
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
|
||||
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
|
||||
_ADDRESS_ARG = (cg.uint8, "address")
|
||||
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
|
||||
_PDU_SPAN = modbus.PDU_SPAN
|
||||
|
||||
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
|
||||
# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import modbus
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ON_RESPONSE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bascht74"]
|
||||
DEPENDENCIES = ["modbus"]
|
||||
DOMAIN = "modbus_monitor"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ON_REQUEST = "on_request"
|
||||
|
||||
|
||||
_HANDLER = automation.validate_synchronous_automation(
|
||||
"modbus_monitor handlers", "the request/response data", single=True
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.Modbus),
|
||||
cv.Optional(CONF_ON_REQUEST): _HANDLER,
|
||||
cv.Optional(CONF_ON_RESPONSE): _HANDLER,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_ON_REQUEST, CONF_ON_RESPONSE),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
|
||||
if on_request := config.get(CONF_ON_REQUEST):
|
||||
await modbus.register_on_request_automation(hub, on_request)
|
||||
if on_response := config.get(CONF_ON_RESPONSE):
|
||||
await modbus.register_on_response_automation(hub, on_response)
|
||||
@@ -452,6 +452,16 @@ bool Nextion::remove_from_q_(bool report_empty) {
|
||||
}
|
||||
NextionComponentBase *component = nb->component;
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
if (!nb->pending_command.empty()) {
|
||||
// Not sent yet, so this response cannot belong to it. Dropping the
|
||||
// response leaves the command queued instead of losing it.
|
||||
ESP_LOGW(TAG, "Response for unsent cmd '%s' (queue %zu), ignoring", component->get_variable_name().c_str(),
|
||||
this->nextion_queue_.size());
|
||||
return false;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str());
|
||||
|
||||
if (is_sleep_wake_command(component)) {
|
||||
@@ -1173,8 +1183,8 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
|
||||
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
|
||||
* sleep-safe and must bypass it.
|
||||
*
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
|
||||
* the command is saved in the queue entry for retry rather than dropped.
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the command cannot go out right
|
||||
* away, it is saved in the queue entry for retry rather than dropped.
|
||||
*
|
||||
* @param variable_name Name of the variable or component associated with the command.
|
||||
* @param command The raw command string to send.
|
||||
@@ -1185,6 +1195,15 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n
|
||||
|
||||
ESP_LOGV(TAG, "Queue NORESULT '%s': %s", variable_name.c_str(), command.c_str());
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Sending now would overtake commands that are still waiting, so only send when
|
||||
// nothing is waiting. process_pending_in_queue_() transmits the rest in order.
|
||||
if (this->has_unsent_commands_()) {
|
||||
this->add_no_result_to_queue_with_pending_command_(variable_name, command);
|
||||
return;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
if (this->send_command_(command)) {
|
||||
this->add_no_result_to_queue_(variable_name);
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
@@ -1346,14 +1365,13 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
|
||||
std::string command = "get " + component->get_variable_name_to_send();
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Always enqueue first so the response handler is present when the command
|
||||
// is eventually sent. Store the command for retry if spacing blocked it;
|
||||
// process_pending_in_queue_() will transmit it when the pacer allows.
|
||||
nextion_queue->pending_command = command;
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
if (this->send_command_(command)) {
|
||||
nextion_queue->pending_command.clear();
|
||||
// Only send right away when nothing is waiting, otherwise this command would
|
||||
// overtake the ones still queued. Anything not sent here keeps its command for
|
||||
// process_pending_in_queue_() to transmit in order.
|
||||
if (this->has_unsent_commands_() || !this->send_command_(command)) {
|
||||
nextion_queue->pending_command = std::move(command);
|
||||
}
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
#else // USE_NEXTION_COMMAND_SPACING
|
||||
if (this->send_command_(command)) {
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
|
||||
@@ -1470,6 +1470,16 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
||||
* Called from loop() to retry sending commands that were delayed by spacing.
|
||||
*/
|
||||
void process_pending_in_queue_();
|
||||
|
||||
/**
|
||||
* @brief Whether the queue still holds a command that has not reached the wire.
|
||||
*
|
||||
* The queue is kept in transmit order, so anything still waiting sits at the back.
|
||||
*/
|
||||
bool has_unsent_commands_() const {
|
||||
return !this->nextion_queue_.empty() && this->nextion_queue_.back() != nullptr &&
|
||||
!this->nextion_queue_.back()->pending_command.empty();
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
std::list<NextionQueue *> nextion_queue_;
|
||||
|
||||
@@ -1302,4 +1302,9 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
if not run_command_ok(genpkg_cmd, env=env, stream_output=True):
|
||||
raise EsphomeError("Failed to create the DFU package")
|
||||
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
from .toolchain import get_idedata
|
||||
|
||||
warn_if_idedata_missing(get_idedata)
|
||||
return True
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis."""
|
||||
"""The native build backend of the nRF52 sdk-nrf toolchain.
|
||||
|
||||
The build itself runs from the platform's ``run_compile`` hook; this module
|
||||
gives the binutils, the linked image and idedata of that build.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,3 +30,17 @@ def get_elf_path() -> Path:
|
||||
if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2):
|
||||
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf")
|
||||
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf")
|
||||
|
||||
|
||||
def get_idedata() -> dict | None:
|
||||
"""Derive idedata from the app's compile_commands.json, next to its
|
||||
``zephyr/`` output directory. Returns None if nothing has been built yet."""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
elf = get_elf_path()
|
||||
# No launcher: Zephyr's CMake leaves ccache out of the compile database
|
||||
return load_or_build_idedata(
|
||||
elf.parent.parent / "compile_commands.json",
|
||||
elf,
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.json"),
|
||||
)
|
||||
@@ -12,7 +12,7 @@ void PipsolarOutput::write_state(float state) {
|
||||
|
||||
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
|
||||
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
|
||||
this->parent_->queue_command(std::string(tmp));
|
||||
this->parent_->queue_command(tmp);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
|
||||
}
|
||||
|
||||
@@ -224,17 +224,23 @@ bool Pipsolar::send_next_poll_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void Pipsolar::queue_command(const std::string &command) {
|
||||
void Pipsolar::write_switch(bool state, const char *on, const char *off) {
|
||||
const char *command = state ? on : off;
|
||||
if (command != nullptr)
|
||||
this->queue_command(command);
|
||||
}
|
||||
|
||||
void Pipsolar::queue_command(const char *command) {
|
||||
uint8_t next_position = command_queue_position_;
|
||||
for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) {
|
||||
uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH;
|
||||
if (command_queue_[testposition].empty()) {
|
||||
command_queue_[testposition] = command;
|
||||
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
|
||||
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command, testposition);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
|
||||
ESP_LOGD(TAG, "Command queue full dropping command: %s", command);
|
||||
}
|
||||
|
||||
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
|
||||
|
||||
@@ -194,7 +194,10 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent {
|
||||
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
|
||||
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
|
||||
|
||||
void queue_command(const std::string &command);
|
||||
void queue_command(const char *command);
|
||||
void queue_command(const std::string &command) { this->queue_command(command.c_str()); }
|
||||
// A switch write; off is nullptr when the inverter has no off command
|
||||
void write_switch(bool state, const char *on, const char *off);
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ICON_POWER
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
|
||||
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
@@ -16,21 +20,55 @@ CONF_INPUT_VOLTAGE_RANGE = "input_voltage_range"
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel"
|
||||
CONF_PV_POWER_BALANCE = "pv_power_balance"
|
||||
|
||||
|
||||
class _PipSwitch(NamedTuple):
|
||||
setter: Callable[[MockObj], MockObj]
|
||||
on: str
|
||||
off: str | None # None when the inverter has no off command
|
||||
|
||||
|
||||
# Each switch's hub setter and its on and off commands
|
||||
TYPES = {
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: ("POP00", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: ("POP01", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: ("POP02", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: ("POP03", None),
|
||||
CONF_INPUT_VOLTAGE_RANGE: ("PGR01", "PGR00"),
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL: ("PPVOKC1", "PPVOKC0"),
|
||||
CONF_PV_POWER_BALANCE: ("PSPB1", "PSPB0"),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_utility_switch,
|
||||
"POP00",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_solar_switch,
|
||||
"POP01",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_battery_switch,
|
||||
"POP02",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_hybrid_switch,
|
||||
"POP03",
|
||||
None,
|
||||
),
|
||||
CONF_INPUT_VOLTAGE_RANGE: _PipSwitch(
|
||||
lambda hub: hub.set_input_voltage_range_switch,
|
||||
"PGR01",
|
||||
"PGR00",
|
||||
),
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL: _PipSwitch(
|
||||
lambda hub: hub.set_pv_ok_condition_for_parallel_switch,
|
||||
"PPVOKC1",
|
||||
"PPVOKC0",
|
||||
),
|
||||
CONF_PV_POWER_BALANCE: _PipSwitch(
|
||||
lambda hub: hub.set_pv_power_balance_switch,
|
||||
"PSPB1",
|
||||
"PSPB0",
|
||||
),
|
||||
}
|
||||
|
||||
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
|
||||
|
||||
PIPSWITCH_SCHEMA = switch.switch_schema(
|
||||
PipsolarSwitch, icon=ICON_POWER, block_inverted=True
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
|
||||
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
|
||||
@@ -38,15 +76,11 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
paren = await cg.get_variable(config[CONF_PIPSOLAR_ID])
|
||||
|
||||
for type, (on, off) in TYPES.items():
|
||||
if type in config:
|
||||
conf = config[type]
|
||||
var = await switch.new_switch(conf)
|
||||
await cg.register_component(var, conf)
|
||||
cg.add(getattr(paren, f"set_{type}_switch")(var))
|
||||
cg.add(var.set_parent(paren))
|
||||
cg.add(var.set_on_command(on))
|
||||
if off is not None:
|
||||
cg.add(var.set_off_command(off))
|
||||
hub = await cg.get_variable(config[CONF_PIPSOLAR_ID])
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
# The hub polls the inverter and publishes each switch's state
|
||||
for key, sw in TYPES.items():
|
||||
off_arg = "nullptr" if sw.off is None else f'"{sw.off}"'
|
||||
await switches(
|
||||
key, sw.setter(hub), apply=f'write_switch(state, "{sw.on}", {off_arg})'
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "pipsolar_switch.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::pipsolar {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "pipsolar.switch");
|
||||
|
||||
void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
|
||||
void PipsolarSwitch::write_state(bool state) {
|
||||
const char *command = state ? this->on_command_ : this->off_command_;
|
||||
if (command != nullptr) {
|
||||
this->parent_->queue_command(command);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::pipsolar
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "../pipsolar.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::pipsolar {
|
||||
class Pipsolar;
|
||||
class PipsolarSwitch final : public switch_::Switch, public Component {
|
||||
public:
|
||||
void set_parent(Pipsolar *parent) { this->parent_ = parent; }
|
||||
void set_on_command(const char *command) { this->on_command_ = command; }
|
||||
void set_off_command(const char *command) { this->off_command_ = command; }
|
||||
/// Prevent accidental use of std::string which would dangle
|
||||
void set_on_command(const std::string &command) = delete;
|
||||
void set_off_command(const std::string &command) = delete;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
const char *on_command_{nullptr};
|
||||
const char *off_command_{nullptr};
|
||||
Pipsolar *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::pipsolar
|
||||
@@ -90,6 +90,7 @@ bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_st
|
||||
|
||||
if (input_stream_info.get_sample_rate() == this->target_sample_rate_) {
|
||||
// The source already delivers the target sample rate, so its audio is passed through unchanged
|
||||
this->output_buffer_.reserve(input_stream_info.ms_to_bytes(BUFFER_DURATION_MS));
|
||||
this->input_stream_info_ = input_stream_info;
|
||||
this->resampler_ready_ = true;
|
||||
return true;
|
||||
@@ -137,7 +138,23 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
|
||||
}
|
||||
|
||||
if (this->resampler_ == nullptr) {
|
||||
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
|
||||
if (this->mute_state_) {
|
||||
// The source's buffer is shared with its other consumers, so publish zeros from our own buffer, in pieces that
|
||||
// fit its reserved size so muting never allocates
|
||||
const uint32_t max_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS);
|
||||
const uint32_t total_frames = input_stream_info.bytes_to_frames(capture.data.size());
|
||||
for (uint32_t done = 0; done < total_frames;) {
|
||||
const uint32_t frames = std::min(total_frames - done, max_frames);
|
||||
this->output_buffer_.assign(input_stream_info.frames_to_bytes(frames), 0);
|
||||
const int64_t time_us =
|
||||
capture.capture_time_us == 0 ? 0 : capture.capture_time_us + input_stream_info.frames_to_microseconds(done);
|
||||
this->publish_audio_(this->output_buffer_, input_stream_info, time_us);
|
||||
done += frames;
|
||||
}
|
||||
} else {
|
||||
// Needs a protected forwarding method once data_callbacks_ is made private in 2027.5.0
|
||||
this->data_callbacks_.call(microphone::AudioCapture{capture.data, input_stream_info, capture.capture_time_us});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <new>
|
||||
|
||||
#ifdef USE_ESP_IDF
|
||||
#include "esp_task_wdt.h"
|
||||
#endif
|
||||
@@ -96,21 +94,10 @@ static int draw_callback(JPEGDRAW *jpeg) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
|
||||
RAMAllocator<JPEGDEC> allocator;
|
||||
JPEGDEC *jpeg = allocator.allocate(1);
|
||||
if (jpeg == nullptr) {
|
||||
JpegDecoder::JpegDecoder(RuntimeImage *image)
|
||||
: ImageDecoder(image, JPEG), jpeg_(RAMAllocator<JPEGDEC>().make_unique()) {
|
||||
if (this->jpeg_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
|
||||
return;
|
||||
}
|
||||
this->jpeg_ = new (jpeg) JPEGDEC();
|
||||
}
|
||||
|
||||
JpegDecoder::~JpegDecoder() {
|
||||
if (this->jpeg_ != nullptr) {
|
||||
this->jpeg_->~JPEGDEC();
|
||||
RAMAllocator<JPEGDEC> allocator;
|
||||
allocator.deallocate(this->jpeg_, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class JpegDecoder : public ImageDecoder {
|
||||
* @param image The RuntimeImage to decode the stream into.
|
||||
*/
|
||||
JpegDecoder(RuntimeImage *image);
|
||||
~JpegDecoder() override;
|
||||
|
||||
int prepare(size_t expected_size) override;
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
@@ -47,8 +46,8 @@ class JpegDecoder : public ImageDecoder {
|
||||
static int32_t source_seek(JPEGFILE *file, int32_t position);
|
||||
static void source_close(void *handle) {}
|
||||
|
||||
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
|
||||
JPEGDEC *jpeg_{nullptr};
|
||||
/// About 18 KB of decoder state: PSRAM first, nullptr if out of memory
|
||||
RAMUniquePtr<JPEGDEC> jpeg_;
|
||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||
bool source_failed_{false};
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.audio_dac import AudioDac
|
||||
from esphome.components.const import CONF_MODULATION
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL
|
||||
from esphome.cpp_generator import MockObj
|
||||
@@ -19,6 +20,7 @@ DEPENDENCIES = ["i2c"]
|
||||
|
||||
CONF_ANALOG_GAIN = "analog_gain"
|
||||
CONF_DAC_MODE = "dac_mode"
|
||||
CONF_FAULT_OUTPUT = "fault_output"
|
||||
CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning"
|
||||
CONF_MIXER_MODE = "mixer_mode"
|
||||
CONF_VOLUME_MIN_DB = "volume_min_db"
|
||||
@@ -39,6 +41,11 @@ MIXER_MODES = MixerMode.options(
|
||||
"stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_"
|
||||
)
|
||||
|
||||
Modulation = tas58xx_ns.enum("Modulation")
|
||||
MODULATIONS = Modulation.options("bd_mode", "1spw_mode", prefix="MODULATION_")
|
||||
|
||||
FAULT_OUTPUT_NONE = "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Model:
|
||||
@@ -49,6 +56,9 @@ class Model:
|
||||
volume_min_db: float
|
||||
volume_max_db: float
|
||||
default_address: int
|
||||
# Pins that can report FAULTZ: name -> pin number n, selected at 0x61 + n (ModelInfo::fault_output_pins)
|
||||
fault_outputs: dict[str, int]
|
||||
default_fault_output: str
|
||||
|
||||
|
||||
MODELS: dict[str, Model] = {
|
||||
@@ -58,6 +68,8 @@ MODELS: dict[str, Model] = {
|
||||
volume_min_db=-103.0,
|
||||
volume_max_db=24.0,
|
||||
default_address=0x2D,
|
||||
fault_outputs={"adr": 0},
|
||||
default_fault_output="adr",
|
||||
),
|
||||
DAC_TAS5825M: Model(
|
||||
model_info=tas58xx_ns.TAS5825M_MODEL,
|
||||
@@ -65,6 +77,8 @@ MODELS: dict[str, Model] = {
|
||||
volume_min_db=-103.0,
|
||||
volume_max_db=24.0,
|
||||
default_address=0x4C,
|
||||
fault_outputs={"gpio0": 0, "gpio1": 1, "gpio2": 2},
|
||||
default_fault_output="gpio1",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -100,9 +114,15 @@ def _model_schema(model: Model) -> cv.Schema:
|
||||
cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum(
|
||||
DAC_MODES, lower=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_FAULT_OUTPUT, default=model.default_fault_output
|
||||
): cv.one_of(FAULT_OUTPUT_NONE, *model.fault_outputs, lower=True),
|
||||
cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum(
|
||||
MIXER_MODES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_MODULATION, default="bd_mode"): cv.enum(
|
||||
MODULATIONS, lower=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_VOLUME_MIN_DB, default=model.volume_min_db
|
||||
): volume_validator,
|
||||
@@ -172,7 +192,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN]))
|
||||
cg.add(var.set_dac_mode(config[CONF_DAC_MODE]))
|
||||
if (fault_output := config[CONF_FAULT_OUTPUT]) != FAULT_OUTPUT_NONE:
|
||||
cg.add(var.set_fault_output(model.fault_outputs[fault_output]))
|
||||
cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE]))
|
||||
cg.add(var.set_modulation(config[CONF_MODULATION]))
|
||||
cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB]))
|
||||
cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB]))
|
||||
await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN))
|
||||
@@ -8,11 +8,8 @@ namespace esphome::tas58xx {
|
||||
// Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet.
|
||||
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep
|
||||
{0x46, 0x01},
|
||||
{0x46, 0x01}, // PPC 96 kHz process flow value (its 48 kHz FIR flows write 0x11)
|
||||
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
|
||||
// The I2C address is latched at power up, after which the ADR pin can report faults
|
||||
{0x61, 0x0B}, // ADR_PIN_CONFIG: FAULTZ
|
||||
{0x60, 0x01}, // ADR_PIN_CTRL: output
|
||||
{0x7D, 0x11},
|
||||
{0x7E, 0xFF},
|
||||
{0x00, 0x01}, // Page 1
|
||||
@@ -25,6 +22,8 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
|
||||
static const LogString *model_name() { return LOG_STR("TAS5805M"); }
|
||||
|
||||
static const LogString *fault_output_name(uint8_t) { return LOG_STR("ADR"); }
|
||||
|
||||
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
|
||||
static const LogString *fault_name(uint8_t index) {
|
||||
if (index == 0)
|
||||
@@ -88,6 +87,8 @@ const ModelInfo TAS5805M_MODEL = {
|
||||
NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING
|
||||
NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING
|
||||
},
|
||||
.fault_output_pins = 1, // ADR
|
||||
.fault_output_name = fault_output_name,
|
||||
};
|
||||
|
||||
} // namespace esphome::tas58xx
|
||||
@@ -6,7 +6,7 @@ namespace esphome::tas58xx {
|
||||
|
||||
// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page.
|
||||
// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k".
|
||||
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
|
||||
// Registers 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
|
||||
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from
|
||||
{0x7D, 0x11},
|
||||
@@ -17,20 +17,25 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x1D, 0x00},
|
||||
{0x19, 0x80},
|
||||
{0x00, 0x00}, // Page 0
|
||||
{0x46, 0x11},
|
||||
{0x46, 0x11}, // DSP_CTRL: 96 kHz processing (bits 4-3 = 10), use default coefficients (bit 0 = 1)
|
||||
// PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML
|
||||
{0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz
|
||||
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
|
||||
|
||||
// Additional register configuration
|
||||
// GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ
|
||||
{0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ
|
||||
{0x60, 0x02}, // GPIO_CTRL: GPIO1 is output
|
||||
{0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults
|
||||
};
|
||||
|
||||
static const LogString *model_name() { return LOG_STR("TAS5825M"); }
|
||||
|
||||
static const LogString *fault_output_name(uint8_t pin) {
|
||||
if (pin == 0)
|
||||
return LOG_STR("GPIO0");
|
||||
if (pin == 1)
|
||||
return LOG_STR("GPIO1");
|
||||
return LOG_STR("GPIO2");
|
||||
}
|
||||
|
||||
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
|
||||
static const LogString *fault_name(uint8_t index) {
|
||||
if (index == 0)
|
||||
@@ -113,6 +118,8 @@ const ModelInfo TAS5825M_MODEL = {
|
||||
// 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included
|
||||
// 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included
|
||||
},
|
||||
.fault_output_pins = 3, // GPIO0, GPIO1, GPIO2 (ADR is an analog input only on TAS5825M)
|
||||
.fault_output_name = fault_output_name,
|
||||
};
|
||||
|
||||
} // namespace esphome::tas58xx
|
||||
@@ -20,6 +20,7 @@ static constexpr uint8_t TAS58XX_RESET_CTRL = 0x01;
|
||||
static constexpr uint8_t TAS58XX_RESET_CTRL_ALL = 0x11; // Reset DSP/control port and registers
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1 = 0x02;
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_PBTL = (1 << 2);
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_1SPW = 0x01; // DAMP_MOD = 01
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2 = 0x03;
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2_MUTE = (1 << 3);
|
||||
static constexpr uint8_t TAS58XX_CTRL_STATE_MASK = 0x03; // DEVICE_CTRL_2 and POWER_STATE
|
||||
@@ -33,6 +34,9 @@ static constexpr uint8_t TAS58XX_DIG_VOL_0DB = 0x30;
|
||||
static constexpr uint8_t TAS58XX_DIG_VOL_MINUS_103DB = 0xFE;
|
||||
static constexpr uint8_t TAS58XX_AGAIN = 0x54; // 0x00 = 0 dB to 0x1F = -15.5 dB in 0.5 dB steps
|
||||
static constexpr uint8_t TAS58XX_AGAIN_MINUS_15_5DB = 0x1F;
|
||||
static constexpr uint8_t TAS58XX_GPIO_CTRL = 0x60; // ADR_PIN_CTRL on TAS5805M: output enable, bit n for pin n
|
||||
static constexpr uint8_t TAS58XX_GPIO0_SEL = 0x61; // ADR_PIN_CONFIG on TAS5805M: pin n is selected at 0x61 + n
|
||||
static constexpr uint8_t TAS58XX_GPIO_SEL_FAULTZ = 0x0B; // pin function: FAULTZ output
|
||||
static constexpr uint8_t TAS58XX_POWER_STATE = 0x68;
|
||||
static constexpr uint8_t TAS58XX_CHAN_FAULT = 0x70; // GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING follow
|
||||
static constexpr uint8_t TAS58XX_FAULT_REGISTER_COUNT = 4;
|
||||
@@ -98,12 +102,26 @@ bool TAS58xx::init_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setters are public, so keep the value inside the register range
|
||||
// The I2C address is latched at power up, so ADR can now be an output. The pin function is written before the
|
||||
// output enable, so the pin never drives another function.
|
||||
if (this->fault_output_pin_ != FAULT_OUTPUT_NONE) {
|
||||
// The setter is public, so only write this model's pins; YAML only allows the pins of the model
|
||||
if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
|
||||
ESP_LOGW(TAG, "Invalid fault output index %u", this->fault_output_pin_);
|
||||
} else if (!this->write_byte(TAS58XX_GPIO0_SEL + this->fault_output_pin_, TAS58XX_GPIO_SEL_FAULTZ) ||
|
||||
!this->write_byte(TAS58XX_GPIO_CTRL, static_cast<uint8_t>(1 << this->fault_output_pin_))) {
|
||||
ESP_LOGE(TAG, "I2C write failed during init");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Setters are public, so keep the values inside the register ranges
|
||||
const uint8_t again =
|
||||
static_cast<uint8_t>(lroundf(clamp(-this->analog_gain_db_ * 2.0f, 0.0f, float{TAS58XX_AGAIN_MINUS_15_5DB})));
|
||||
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) ||
|
||||
!this->write_byte(TAS58XX_AGAIN, again) || !this->write_volume_() ||
|
||||
!this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
|
||||
const uint8_t device_ctrl_1 = (this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) |
|
||||
(this->modulation_ == MODULATION_1SPW_MODE ? TAS58XX_DEVICE_CTRL_1_1SPW : 0);
|
||||
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, device_ctrl_1) || !this->write_byte(TAS58XX_AGAIN, again) ||
|
||||
!this->write_volume_() || !this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
|
||||
!this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) {
|
||||
ESP_LOGE(TAG, "I2C write failed during init");
|
||||
return false;
|
||||
@@ -263,6 +281,14 @@ void TAS58xx::dump_config() {
|
||||
} else {
|
||||
mixer_mode = LOG_STR("Stereo");
|
||||
}
|
||||
const LogString *fault_output;
|
||||
if (this->fault_output_pin_ == FAULT_OUTPUT_NONE) {
|
||||
fault_output = LOG_STR("None");
|
||||
} else if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
|
||||
fault_output = LOG_STR("Invalid");
|
||||
} else {
|
||||
fault_output = this->model_->fault_output_name(this->fault_output_pin_);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio Amplifier:\n"
|
||||
" Model: %s",
|
||||
@@ -273,11 +299,14 @@ void TAS58xx::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Analog Gain: %.1f dB\n"
|
||||
" DAC Mode: %s\n"
|
||||
" Modulation: %s\n"
|
||||
" Mixer Mode: %s\n"
|
||||
" Volume Range: %.1f dB to %.1f dB",
|
||||
" Volume Range: %.1f dB to %.1f dB\n"
|
||||
" Fault Output: %s",
|
||||
this->analog_gain_db_,
|
||||
this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"),
|
||||
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_);
|
||||
this->modulation_ == MODULATION_1SPW_MODE ? LOG_STR_LITERAL("1SPW") : LOG_STR_LITERAL("BD"),
|
||||
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_, LOG_STR_ARG(fault_output));
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_);
|
||||
for (auto *sensor : this->fault_binary_sensors_) {
|
||||
|
||||
@@ -22,6 +22,12 @@ enum DacMode : uint8_t {
|
||||
DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker
|
||||
};
|
||||
|
||||
/// DEVICE_CTRL_1 DAMP_MOD values, the same on all models
|
||||
enum Modulation : uint8_t {
|
||||
MODULATION_BD_MODE = 0,
|
||||
MODULATION_1SPW_MODE = 1,
|
||||
};
|
||||
|
||||
enum MixerMode : uint8_t {
|
||||
MIXER_MODE_STEREO = 0,
|
||||
MIXER_MODE_STEREO_INVERSE,
|
||||
@@ -64,6 +70,8 @@ static_assert(
|
||||
|
||||
/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have.
|
||||
static constexpr uint8_t NO_BIT = 0xFF;
|
||||
/// fault_output_pin_ value for no fault output
|
||||
static constexpr uint8_t FAULT_OUTPUT_NONE = 0xFF;
|
||||
|
||||
/// Everything that differs between models of the family. One constant instance exists per model, see
|
||||
/// model_*.cpp, and each TAS58xx instance points to the one for its model.
|
||||
@@ -91,6 +99,10 @@ struct ModelInfo {
|
||||
const LogString *(*fault_name)(uint8_t index);
|
||||
/// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit
|
||||
uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT];
|
||||
/// Number of pins that can report FAULTZ: pin n is selected at 0x61 + n and enabled by bit n of 0x60
|
||||
uint8_t fault_output_pins;
|
||||
/// Name of FAULTZ pin n, for dump_config
|
||||
const LogString *(*fault_output_name)(uint8_t pin);
|
||||
};
|
||||
|
||||
#ifdef USE_TAS58XX_TAS5805M
|
||||
@@ -127,7 +139,10 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
|
||||
void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; }
|
||||
void set_analog_gain(float analog_gain_db) { this->analog_gain_db_ = analog_gain_db; }
|
||||
void set_dac_mode(DacMode dac_mode) { this->dac_mode_ = dac_mode; }
|
||||
/// pin: 0 to 2 for GPIOn on TAS5825M, 0 for ADR on TAS5805M; leave unset for no fault output
|
||||
void set_fault_output(uint8_t pin) { this->fault_output_pin_ = pin; }
|
||||
void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; }
|
||||
void set_modulation(Modulation modulation) { this->modulation_ = modulation; }
|
||||
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
|
||||
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
|
||||
|
||||
@@ -171,7 +186,9 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
|
||||
float volume_max_db_{24.0f};
|
||||
uint32_t logged_faults_{0}; // Fault bits as last logged, packed like the fault masks
|
||||
DacMode dac_mode_{DAC_MODE_BTL};
|
||||
uint8_t fault_output_pin_{FAULT_OUTPUT_NONE}; // No fault output: the pin keeps its power on state
|
||||
MixerMode mixer_mode_{MIXER_MODE_STEREO};
|
||||
Modulation modulation_{MODULATION_BD_MODE};
|
||||
uint8_t ctrl_state_{0};
|
||||
uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read
|
||||
bool mixer_written_{false}; // Mixer written since the last reset
|
||||
|
||||
@@ -124,6 +124,7 @@ CoverTraits TemplateCover::get_traits() {
|
||||
Trigger<float> *TemplateCover::get_position_trigger() { return &this->position_trigger_; }
|
||||
Trigger<float> *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; }
|
||||
void TemplateCover::stop_prev_trigger_() {
|
||||
this->position_trigger_.stop_action();
|
||||
if (this->prev_command_trigger_ != nullptr) {
|
||||
this->prev_command_trigger_->stop_action();
|
||||
this->prev_command_trigger_ = nullptr;
|
||||
|
||||
@@ -5,25 +5,37 @@ import esphome.codegen as cg
|
||||
from esphome.components import display, network, socket
|
||||
from esphome.components.const import (
|
||||
BYTE_ORDER_LITTLE,
|
||||
CONF_BUTTONS,
|
||||
CONF_HOST,
|
||||
CONF_RECONNECT_INTERVAL,
|
||||
)
|
||||
from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTION,
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DISPLAY,
|
||||
CONF_DISPLAY_ID,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_TEXT,
|
||||
CONF_X,
|
||||
CONF_Y,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
from .keysyms import button_mask, keysym
|
||||
|
||||
CODEOWNERS = ["@MindFreeze"]
|
||||
DEPENDENCIES = ["display", "network"]
|
||||
AUTO_LOAD = ["socket"]
|
||||
AUTO_LOAD = ["socket", "unicode"]
|
||||
DOMAIN = "vnc"
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -34,11 +46,14 @@ CONF_PIXEL_FORMAT = "pixel_format"
|
||||
vnc_ns = cg.esphome_ns.namespace("vnc")
|
||||
VncClient = vnc_ns.class_("VncClient", cg.Component)
|
||||
VncPixelFormat = vnc_ns.enum("VncPixelFormat", is_class=True)
|
||||
VncKeyAction = vnc_ns.enum("VncKeyAction", is_class=True)
|
||||
|
||||
PIXEL_FORMATS = VncPixelFormat.options(
|
||||
"auto", "rgb565", "rgb332", prefix="VNC_PIXEL_FORMAT_"
|
||||
)
|
||||
|
||||
KEY_ACTIONS = VncKeyAction.options("press", "down", "up", prefix="VNC_KEY_ACTION_")
|
||||
|
||||
# Drivers that cannot take another component's pixels yet.
|
||||
UNSUPPORTED_DISPLAYS = dict.fromkeys(
|
||||
("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet"
|
||||
@@ -72,6 +87,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_PIXEL_FORMAT, default="auto"): cv.enum(
|
||||
PIXEL_FORMATS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen),
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
|
||||
}
|
||||
@@ -115,6 +131,14 @@ def _final_validate(config: ConfigType) -> None:
|
||||
raise cv.Invalid(
|
||||
f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID]
|
||||
)
|
||||
if (ts_id := config.get(CONF_TOUCHSCREEN_ID)) is not None:
|
||||
ts_display = fv.get_declaring_config(ts_id).get(CONF_DISPLAY)
|
||||
if ts_display is not None and ts_display.id != disp_id.id:
|
||||
raise cv.Invalid(
|
||||
f"Touchscreen '{ts_id}' belongs to display '{ts_display}', "
|
||||
f"not '{disp_id}'",
|
||||
path=[CONF_TOUCHSCREEN_ID],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -138,15 +162,89 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
|
||||
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
|
||||
cg.add(var.set_pixel_format(config[CONF_PIXEL_FORMAT]))
|
||||
if (
|
||||
await cg.add_variable(var.register_touchscreen, config.get(CONF_TOUCHSCREEN_ID))
|
||||
is not None
|
||||
):
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
cg.add_define("USE_VNC_TOUCHSCREEN")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(VncClient)},
|
||||
)
|
||||
_CLIENT_ID = {cv.GenerateID(): cv.use_id(VncClient)}
|
||||
|
||||
VNC_ID_SCHEMA = automation.maybe_simple_id(_CLIENT_ID)
|
||||
|
||||
automation.register_apply_action(
|
||||
"vnc.refresh", VNC_ID_SCHEMA, automation.ApplyCall("refresh()")
|
||||
)
|
||||
|
||||
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
|
||||
|
||||
|
||||
def _register_input_action(
|
||||
name: str, schema: cv.Schema, *fields: automation.ApplyField | automation.ApplyCall
|
||||
) -> None:
|
||||
builder = automation.apply_action_builder(name, schema, *fields)
|
||||
|
||||
@automation.register_action(name, automation.ApplyAction, schema, synchronous=True)
|
||||
async def input_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_VNC_INPUT")
|
||||
return await builder(config, action_id, template_arg, args)
|
||||
|
||||
|
||||
_register_input_action(
|
||||
"vnc.send_pointer",
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_X): cv.templatable(cv.uint16_t),
|
||||
cv.Required(CONF_Y): cv.templatable(cv.uint16_t),
|
||||
cv.Optional(CONF_BUTTONS, default=0): cv.templatable(button_mask),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_pointer({}, {}, {})",
|
||||
((CONF_X, cg.uint16), (CONF_Y, cg.uint16), (CONF_BUTTONS, cg.uint8)),
|
||||
),
|
||||
)
|
||||
|
||||
_register_input_action(
|
||||
"vnc.send_key",
|
||||
automation.maybe_conf(
|
||||
CONF_KEY,
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_KEY): cv.templatable(keysym),
|
||||
cv.Optional(CONF_ACTION, default="press"): cv.enum(
|
||||
KEY_ACTIONS, lower=True
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_key({}, {})", ((CONF_KEY, cg.uint32), (CONF_ACTION, VncKeyAction))
|
||||
),
|
||||
)
|
||||
|
||||
_register_input_action(
|
||||
"vnc.type_text",
|
||||
automation.maybe_conf(
|
||||
CONF_TEXT,
|
||||
cv.Schema(
|
||||
{
|
||||
**_CLIENT_ID,
|
||||
cv.Required(CONF_TEXT): cv.templatable(cv.string),
|
||||
}
|
||||
),
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TEXT, "type_text", cg.std_string, const_fn=automation.literal_with_length
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""X11 keysyms and pointer button masks for VNC input, resolved at code generation."""
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import HexInt
|
||||
|
||||
_KEYSYM_MAX = 0x1FFFFFFF
|
||||
# X11 encodes a Unicode character outside Latin-1 as this prefix plus its code point
|
||||
_UNICODE_KEYSYM = 0x01000000
|
||||
|
||||
KEYSYMS: dict[str, int] = {
|
||||
"BackSpace": 0xFF08,
|
||||
"Tab": 0xFF09,
|
||||
"Return": 0xFF0D,
|
||||
"Pause": 0xFF13,
|
||||
"Scroll_Lock": 0xFF14,
|
||||
"Escape": 0xFF1B,
|
||||
"Home": 0xFF50,
|
||||
"Left": 0xFF51,
|
||||
"Up": 0xFF52,
|
||||
"Right": 0xFF53,
|
||||
"Down": 0xFF54,
|
||||
"Page_Up": 0xFF55,
|
||||
"Page_Down": 0xFF56,
|
||||
"End": 0xFF57,
|
||||
"Print": 0xFF61,
|
||||
"Insert": 0xFF63,
|
||||
"Menu": 0xFF67,
|
||||
"Num_Lock": 0xFF7F,
|
||||
"KP_Enter": 0xFF8D,
|
||||
**{f"F{n}": 0xFFBE + n - 1 for n in range(1, 13)},
|
||||
"Shift_L": 0xFFE1,
|
||||
"Shift_R": 0xFFE2,
|
||||
"Control_L": 0xFFE3,
|
||||
"Control_R": 0xFFE4,
|
||||
"Caps_Lock": 0xFFE5,
|
||||
"Meta_L": 0xFFE7,
|
||||
"Meta_R": 0xFFE8,
|
||||
"Alt_L": 0xFFE9,
|
||||
"Alt_R": 0xFFEA,
|
||||
"Super_L": 0xFFEB,
|
||||
"Super_R": 0xFFEC,
|
||||
"Delete": 0xFFFF,
|
||||
"space": 0x20,
|
||||
"ISO_Left_Tab": 0xFE20,
|
||||
}
|
||||
|
||||
ALIASES: dict[str, str] = {
|
||||
"alt": "Alt_L",
|
||||
"ctrl": "Control_L",
|
||||
"del": "Delete",
|
||||
"enter": "Return",
|
||||
"esc": "Escape",
|
||||
"ins": "Insert",
|
||||
"next": "Page_Down",
|
||||
"pgdn": "Page_Down",
|
||||
"pgup": "Page_Up",
|
||||
"prior": "Page_Up",
|
||||
"shift": "Shift_L",
|
||||
"super": "Super_L",
|
||||
}
|
||||
|
||||
_BY_LOWER_NAME: dict[str, int] = {
|
||||
**{name.lower(): code for name, code in KEYSYMS.items()},
|
||||
**{alias: KEYSYMS[name] for alias, name in ALIASES.items()},
|
||||
}
|
||||
|
||||
BUTTONS: dict[str, int] = {
|
||||
"left": 1,
|
||||
"middle": 2,
|
||||
"right": 4,
|
||||
"wheel_up": 8,
|
||||
"wheel_down": 16,
|
||||
}
|
||||
|
||||
_BUTTON_NAMES = cv.ensure_list(cv.one_of(*BUTTONS, lower=True))
|
||||
|
||||
|
||||
_keysym_number = cv.hex_int_range(min=0, max=_KEYSYM_MAX)
|
||||
|
||||
|
||||
# Keep in step with keysym_for_codepoint() in vnc_client.cpp.
|
||||
def _character_keysym(char: str) -> HexInt:
|
||||
code = ord(char)
|
||||
if 0x20 <= code <= 0x7E or 0xA0 <= code <= 0xFF:
|
||||
return HexInt(code)
|
||||
if code <= 0x9F:
|
||||
raise cv.Invalid(
|
||||
f"Control character U+{code:04X} is not a key. Use a key name such as Return, Tab or BackSpace"
|
||||
)
|
||||
return HexInt(_UNICODE_KEYSYM | code)
|
||||
|
||||
|
||||
def keysym(value: object) -> HexInt:
|
||||
"""An X11 keysym from a key name, a single character or a number.
|
||||
|
||||
An unquoted digit 0 to 9 is read as that digit, as in tinyusb_keyboard. Names are matched
|
||||
without regard to case, but a single character is always that character, so "a" and "A" differ.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
raise cv.Invalid(
|
||||
'YAML read this key as a boolean. Put it in quotes, for example key: "y"'
|
||||
)
|
||||
if isinstance(value, int):
|
||||
if 0 <= value <= 9:
|
||||
return HexInt(ord("0") + value)
|
||||
return _keysym_number(value)
|
||||
if not isinstance(value, str):
|
||||
raise cv.Invalid(
|
||||
f"Expected a key name, a single character or a keysym number, got {value!r}"
|
||||
)
|
||||
if len(value) == 1:
|
||||
return _character_keysym(value)
|
||||
lower = value.lower()
|
||||
if lower.startswith("0x"):
|
||||
return _keysym_number(value)
|
||||
if (code := _BY_LOWER_NAME.get(lower)) is not None:
|
||||
return HexInt(code)
|
||||
raise cv.Invalid(
|
||||
f"Unknown key '{value}'. Use a single character, a keysym number such as 0xFF0D, "
|
||||
f""
|
||||
"or a key name such as Return, Escape, Tab, BackSpace, Delete, Left, Page_Up, F1 or Control_L"
|
||||
)
|
||||
|
||||
|
||||
def button_mask(value: object) -> int:
|
||||
"""A pointer button mask from a number 0 to 255, or one or more button names."""
|
||||
if isinstance(value, bool):
|
||||
raise cv.Invalid(
|
||||
f"Expected a button mask or button names ({', '.join(BUTTONS)}), got a boolean"
|
||||
)
|
||||
if isinstance(value, int):
|
||||
return cv.uint8_t(value)
|
||||
mask = 0
|
||||
for name in _BUTTON_NAMES(value):
|
||||
mask |= BUTTONS[name]
|
||||
return mask
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "vnc_client.h"
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/components/unicode/unicode.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
|
||||
@@ -53,6 +55,17 @@ static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
|
||||
static constexpr uint8_t MSG_BELL = 2;
|
||||
static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3;
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
static constexpr uint8_t MSG_KEY_EVENT = 4;
|
||||
static constexpr uint8_t MSG_POINTER_EVENT = 5;
|
||||
|
||||
static constexpr uint32_t KEYSYM_BACKSPACE = 0xFF08;
|
||||
static constexpr uint32_t KEYSYM_TAB = 0xFF09;
|
||||
static constexpr uint32_t KEYSYM_RETURN = 0xFF0D;
|
||||
// X11 encodes a character outside Latin-1 as this prefix plus its code point.
|
||||
static constexpr uint32_t KEYSYM_UNICODE = 0x01000000;
|
||||
#endif
|
||||
|
||||
static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); }
|
||||
static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); }
|
||||
static inline void write_be16(uint8_t *p, uint16_t v) {
|
||||
@@ -77,8 +90,59 @@ static int parse_three_digits(const uint8_t *p) {
|
||||
return value;
|
||||
}
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
// Keep in step with _character_keysym() in keysyms.py.
|
||||
static uint32_t keysym_for_codepoint(uint32_t cp) {
|
||||
switch (cp) {
|
||||
case '\n':
|
||||
return KEYSYM_RETURN;
|
||||
case '\t':
|
||||
return KEYSYM_TAB;
|
||||
case '\b':
|
||||
return KEYSYM_BACKSPACE;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (cp < 0x20 || (cp >= 0x7F && cp < 0xA0)) {
|
||||
return 0;
|
||||
}
|
||||
return cp <= 0xFF ? cp : (KEYSYM_UNICODE | cp);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
static constexpr uint16_t TOUCH_SLOP_PX = 8;
|
||||
|
||||
static bool is_down(const touchscreen::TouchPoint &tp) {
|
||||
// A lifted finger lingers in the list as RELEASED or RELEASING until every finger is up.
|
||||
return (tp.state & (touchscreen::STATE_PRESSED | touchscreen::STATE_UPDATED)) != 0 &&
|
||||
(tp.state & touchscreen::STATE_RELEASING) == 0;
|
||||
}
|
||||
|
||||
// A first touch at (0, 0) matches a new point's default previous position, so only touch() reports it.
|
||||
void VncClient::touch(touchscreen::TouchPoint tp) {
|
||||
if (is_down(tp)) {
|
||||
this->handle_touch_(tp.x, tp.y);
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::update(const touchscreen::TouchPoints_t &tpoints) {
|
||||
for (const auto &tp : tpoints) {
|
||||
if (is_down(tp)) {
|
||||
this->handle_touch_(tp.x, tp.y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::release() { this->handle_release_(); }
|
||||
#endif
|
||||
|
||||
void VncClient::setup() {
|
||||
this->setup_ms_ = App.get_loop_component_start_time();
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->last_input_ms_ = this->setup_ms_ - INPUT_BOOST_MS; // no input yet
|
||||
#endif
|
||||
this->link_.begin(TAG);
|
||||
// Sized for the display now; a rotation changed later swaps the sides but keeps the cell count.
|
||||
const uint32_t wanted = static_cast<uint32_t>((this->display_->get_width() + 15) / 16) *
|
||||
@@ -121,6 +185,9 @@ void VncClient::dump_config() {
|
||||
this->max_update_delay_ms_,
|
||||
LOG_STR_ARG(PixelFormatStrings::get_log_str(static_cast<uint8_t>(this->pixel_format_), 0)),
|
||||
this->hash_capacity_);
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
ESP_LOGCONFIG(TAG, " Touchscreen: %s", this->has_touchscreen_ ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"));
|
||||
#endif
|
||||
}
|
||||
|
||||
void VncClient::on_shutdown() { this->link_.close(); }
|
||||
@@ -195,10 +262,15 @@ void VncClient::loop() {
|
||||
// A display still starting up (it8951) can go from its start-up straight into a refresh without parking.
|
||||
const bool display_starting =
|
||||
!this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS;
|
||||
uint32_t update_delay = this->update_delay_ms_;
|
||||
#ifdef USE_VNC_INPUT
|
||||
if (now - this->last_input_ms_ < INPUT_BOOST_MS) {
|
||||
update_delay = 0;
|
||||
}
|
||||
#endif
|
||||
// Wait until the screen stops changing, so an animation becomes one refresh showing where it ended.
|
||||
if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting &&
|
||||
(now - this->last_change_ms_ >= this->update_delay_ms_ ||
|
||||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
|
||||
(now - this->last_change_ms_ >= update_delay || now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
|
||||
// Synchronous displays flush inside update(), so this pass does nothing else.
|
||||
this->refresh_pending_ = false;
|
||||
this->display_->update();
|
||||
@@ -207,6 +279,9 @@ void VncClient::loop() {
|
||||
this->service_rx_(now);
|
||||
}
|
||||
if (this->state_ == VncState::VNC_STATE_RUNNING) {
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->send_pending_();
|
||||
#endif
|
||||
// A new session lays the screen out again for the new rotation and redraws all of it.
|
||||
if (static_cast<uint8_t>(this->display_->get_rotation() / 90) != this->hash_rotation_) {
|
||||
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display rotation changed"));
|
||||
@@ -263,6 +338,13 @@ void VncClient::reset_session_() {
|
||||
this->refresh_pending_ = false;
|
||||
this->need_full_ = false;
|
||||
this->frame_drawn_ = false;
|
||||
#ifdef USE_VNC_INPUT
|
||||
this->pending_count_ = 0;
|
||||
this->ptr_buttons_ = 0;
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
this->touching_ = false;
|
||||
#endif
|
||||
this->step_ = VncStep::VNC_STEP_VERSION;
|
||||
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
|
||||
this->high_freq_.stop();
|
||||
@@ -984,4 +1066,144 @@ bool VncClient::send_update_request_(bool incremental, uint32_t now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool VncClient::send_key_event_(uint32_t keysym, VncKeyAction action) {
|
||||
// A press is the down and the up event, sent whole or not at all.
|
||||
uint8_t msg[2 * KEY_EVENT_SIZE] = {MSG_KEY_EVENT, static_cast<uint8_t>(action != VncKeyAction::VNC_KEY_ACTION_UP)};
|
||||
msg[KEY_EVENT_SIZE] = MSG_KEY_EVENT;
|
||||
write_be32(msg + 4, keysym);
|
||||
write_be32(msg + KEY_EVENT_SIZE + 4, keysym);
|
||||
return this->send_input_(msg, action == VncKeyAction::VNC_KEY_ACTION_PRESS ? sizeof(msg) : KEY_EVENT_SIZE,
|
||||
action == VncKeyAction::VNC_KEY_ACTION_UP);
|
||||
}
|
||||
|
||||
// Input goes out after the releases that are still waiting. A release that does not fit waits for room,
|
||||
// because a key or button left down would change every later key and click; other input is dropped.
|
||||
bool VncClient::send_input_(const uint8_t *msg, size_t len, bool release) {
|
||||
if (this->send_pending_() && this->send_(msg, len)) {
|
||||
this->note_input_();
|
||||
return true;
|
||||
}
|
||||
if (!release) {
|
||||
return false;
|
||||
}
|
||||
if (this->pending_count_ == MAX_PENDING_RELEASES) {
|
||||
// Losing a release would leave its key or button held on the server
|
||||
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Too many releases waiting for the send buffer"));
|
||||
return false;
|
||||
}
|
||||
std::memcpy(this->pending_[this->pending_count_++], msg, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VncClient::send_pending_() {
|
||||
while (this->pending_count_ != 0) {
|
||||
const uint8_t *msg = this->pending_[0];
|
||||
if (!this->send_(msg, msg[0] == MSG_POINTER_EVENT ? POINTER_EVENT_SIZE : KEY_EVENT_SIZE)) {
|
||||
return false;
|
||||
}
|
||||
this->note_input_();
|
||||
this->pending_count_--;
|
||||
std::memmove(this->pending_[0], this->pending_[1], this->pending_count_ * sizeof(this->pending_[0]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VncClient::send_pointer(uint16_t x, uint16_t y, uint8_t buttons) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, pointer event dropped");
|
||||
return false;
|
||||
}
|
||||
x = std::min<uint16_t>(x, this->fb_w_ - 1);
|
||||
y = std::min<uint16_t>(y, this->fb_h_ - 1);
|
||||
uint8_t msg[POINTER_EVENT_SIZE] = {MSG_POINTER_EVENT, buttons};
|
||||
write_be16(msg + 2, x);
|
||||
write_be16(msg + 4, y);
|
||||
const bool release = buttons != this->ptr_buttons_ && (buttons & ~this->ptr_buttons_) == 0;
|
||||
if (!this->send_input_(msg, sizeof(msg), release)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, pointer event dropped");
|
||||
return false;
|
||||
}
|
||||
this->ptr_x_ = x;
|
||||
this->ptr_y_ = y;
|
||||
this->ptr_buttons_ = buttons;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VncClient::note_input_() { this->last_input_ms_ = App.get_loop_component_start_time(); }
|
||||
|
||||
void VncClient::send_key(uint32_t keysym, VncKeyAction action) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, key dropped");
|
||||
return;
|
||||
}
|
||||
if (!this->send_key_event_(keysym, action)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, key dropped");
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::type_text(const char *text, size_t len) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "Not connected, text dropped");
|
||||
return;
|
||||
}
|
||||
size_t typed = 0;
|
||||
for (size_t i = 0; i < len;) {
|
||||
// The decoder reads up to a NUL, so give it only the bytes that belong to this text.
|
||||
char sequence[5] = {};
|
||||
std::memcpy(sequence, text + i, std::min<size_t>(4, len - i));
|
||||
size_t used;
|
||||
const uint32_t cp = unicode::extract_unicode_codepoint(sequence, &used);
|
||||
// A broken sequence or a NUL types nothing; carry on at the next byte.
|
||||
i += used == 0 ? 1 : used;
|
||||
const uint32_t keysym = keysym_for_codepoint(cp);
|
||||
if (keysym == 0) {
|
||||
continue;
|
||||
}
|
||||
if (!this->send_key_event_(keysym, VncKeyAction::VNC_KEY_ACTION_PRESS)) {
|
||||
ESP_LOGW(TAG, "Send buffer full, text cut after %u characters", static_cast<unsigned>(typed));
|
||||
return;
|
||||
}
|
||||
typed++;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
void VncClient::handle_touch_(uint16_t x, uint16_t y) {
|
||||
if (!this->is_connected()) {
|
||||
return;
|
||||
}
|
||||
if (!this->touching_) {
|
||||
if (x >= this->vis_w_ || y >= this->vis_h_) {
|
||||
return;
|
||||
}
|
||||
this->press_x_ = x;
|
||||
this->press_y_ = y;
|
||||
this->dragging_ = false;
|
||||
this->touching_ = this->send_pointer(x, y, 1);
|
||||
return;
|
||||
}
|
||||
x = std::min<uint16_t>(x, this->vis_w_ - 1);
|
||||
y = std::min<uint16_t>(y, this->vis_h_ - 1);
|
||||
// Small jitter during a tap would start a text selection in a browser.
|
||||
if (!this->dragging_) {
|
||||
if (std::abs(x - this->press_x_) <= TOUCH_SLOP_PX && std::abs(y - this->press_y_) <= TOUCH_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
this->dragging_ = true;
|
||||
}
|
||||
if (x != this->ptr_x_ || y != this->ptr_y_) {
|
||||
this->send_pointer(x, y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void VncClient::handle_release_() {
|
||||
if (this->touching_) {
|
||||
this->touching_ = false;
|
||||
this->send_pointer(this->ptr_x_, this->ptr_y_, 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::vnc
|
||||
@@ -4,9 +4,13 @@
|
||||
#include "esphome/components/socket/tcp_client_link.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
#include "esphome/components/touchscreen/touchscreen.h"
|
||||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::vnc {
|
||||
|
||||
@@ -16,6 +20,12 @@ enum class VncPixelFormat : uint8_t {
|
||||
VNC_PIXEL_FORMAT_RGB332,
|
||||
};
|
||||
|
||||
enum class VncKeyAction : uint8_t {
|
||||
VNC_KEY_ACTION_PRESS,
|
||||
VNC_KEY_ACTION_DOWN,
|
||||
VNC_KEY_ACTION_UP,
|
||||
};
|
||||
|
||||
enum class VncState : uint8_t {
|
||||
VNC_STATE_IDLE,
|
||||
VNC_STATE_HANDSHAKE,
|
||||
@@ -55,7 +65,12 @@ enum class VncFailure : uint8_t {
|
||||
};
|
||||
|
||||
/// RFB (VNC) client that draws the remote framebuffer into a display.
|
||||
class VncClient : public Component {
|
||||
class VncClient : public Component
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
,
|
||||
public touchscreen::TouchListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
|
||||
|
||||
@@ -65,6 +80,15 @@ class VncClient : public Component {
|
||||
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
|
||||
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
|
||||
void set_pixel_format(VncPixelFormat format) { this->pixel_format_ = format; }
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
void register_touchscreen(touchscreen::Touchscreen *touchscreen) {
|
||||
touchscreen->register_listener(this);
|
||||
this->has_touchscreen_ = true;
|
||||
}
|
||||
void touch(touchscreen::TouchPoint tp) override;
|
||||
void update(const touchscreen::TouchPoints_t &tpoints) override;
|
||||
void release() override;
|
||||
#endif
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -76,6 +100,13 @@ class VncClient : public Component {
|
||||
/// Ask the server for the whole screen again.
|
||||
void refresh();
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool send_pointer(uint16_t x, uint16_t y, uint8_t buttons);
|
||||
void send_key(uint32_t keysym, VncKeyAction action);
|
||||
void type_text(const char *text, size_t len);
|
||||
void type_text(const std::string &text) { this->type_text(text.data(), text.size()); }
|
||||
#endif
|
||||
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -86,6 +117,13 @@ class VncClient : public Component {
|
||||
protected:
|
||||
static constexpr size_t RX_BUFFER_SIZE = 512;
|
||||
static constexpr size_t TILE_BUFFER_SIZE = 16 * 16 * 2;
|
||||
#ifdef USE_VNC_INPUT
|
||||
// For this long after input, frames are painted as soon as they arrive: the reaction matters more than a calm panel.
|
||||
static constexpr uint32_t INPUT_BOOST_MS = 2000;
|
||||
static constexpr size_t KEY_EVENT_SIZE = 8;
|
||||
static constexpr size_t POINTER_EVENT_SIZE = 6;
|
||||
static constexpr size_t MAX_PENDING_RELEASES = 4;
|
||||
#endif
|
||||
|
||||
void connect_step_(uint32_t now);
|
||||
void start_handshake_(uint32_t now);
|
||||
@@ -107,6 +145,17 @@ class VncClient : public Component {
|
||||
bool budget_exhausted_() const;
|
||||
bool send_(const uint8_t *data, size_t len);
|
||||
bool send_update_request_(bool incremental, uint32_t now);
|
||||
#ifdef USE_VNC_INPUT
|
||||
bool send_key_event_(uint32_t keysym, VncKeyAction action);
|
||||
bool send_input_(const uint8_t *msg, size_t len, bool release);
|
||||
bool send_pending_();
|
||||
void note_input_();
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
/// Touch input in display coordinates.
|
||||
void handle_touch_(uint16_t x, uint16_t y);
|
||||
void handle_release_();
|
||||
#endif
|
||||
VncParse fail_(VncFailure failure, const LogString *reason);
|
||||
VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); }
|
||||
VncFailure lost_failure_() const;
|
||||
@@ -135,6 +184,9 @@ class VncClient : public Component {
|
||||
// The first and the latest frame drawn since the display last refreshed.
|
||||
uint32_t first_change_ms_{0};
|
||||
uint32_t last_change_ms_{0};
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint32_t last_input_ms_{0};
|
||||
#endif
|
||||
uint32_t rx_total_{0};
|
||||
uint32_t update_start_bytes_{0};
|
||||
uint32_t last_request_ms_{0};
|
||||
@@ -161,7 +213,19 @@ class VncClient : public Component {
|
||||
uint16_t hash_capacity_{0};
|
||||
uint16_t hash_cols_{0};
|
||||
uint16_t hash_rows_{0};
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint16_t ptr_x_{0};
|
||||
uint16_t ptr_y_{0};
|
||||
#endif
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
uint16_t press_x_{0};
|
||||
uint16_t press_y_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_VNC_INPUT
|
||||
uint8_t ptr_buttons_{0}; // as the server will see them once the waiting releases are sent
|
||||
uint8_t pending_count_{0};
|
||||
#endif
|
||||
uint8_t tile_w_{0};
|
||||
uint8_t tile_h_{0};
|
||||
uint8_t subrects_left_{0};
|
||||
@@ -193,9 +257,18 @@ class VncClient : public Component {
|
||||
bool busy_warned_{false};
|
||||
bool refresh_retried_{false}; // one retry per refresh the driver abandoned
|
||||
bool network_was_up_{false};
|
||||
#ifdef USE_VNC_TOUCHSCREEN
|
||||
bool has_touchscreen_{false};
|
||||
bool dragging_{false};
|
||||
bool touching_{false};
|
||||
#endif
|
||||
bool big_endian_; // the pixel order the display takes
|
||||
|
||||
uint8_t rx_[RX_BUFFER_SIZE];
|
||||
#ifdef USE_VNC_INPUT
|
||||
// Key-up and button-release messages waiting for room, oldest first.
|
||||
uint8_t pending_[MAX_PENDING_RELEASES][KEY_EVENT_SIZE];
|
||||
#endif
|
||||
alignas(4) uint8_t tile_[TILE_BUFFER_SIZE];
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
from esphome.components.nrf52.framework import (
|
||||
bluetooth_west_projects,
|
||||
include_west_project,
|
||||
@@ -54,8 +55,6 @@ def request_ble_l2cap_mtu(l2cap_mtu: int) -> None:
|
||||
zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server")
|
||||
BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component)
|
||||
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
|
||||
CONF_ACCEPT = "accept"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
|
||||
@@ -2744,8 +2744,13 @@ def url(value):
|
||||
raise Invalid("Expected a file scheme or a URL scheme with host")
|
||||
|
||||
|
||||
def git_ref(value):
|
||||
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
|
||||
# Characters git never allows in a branch or tag name (git check-ref-format)
|
||||
GIT_REF_PATTERN = r"[^\s~^:?*\[\\]+"
|
||||
|
||||
|
||||
def git_ref(value: typing.Any) -> str:
|
||||
value = string_strict(value)
|
||||
if re.fullmatch(GIT_REF_PATTERN, value) is None:
|
||||
raise Invalid("Not a valid git ref")
|
||||
return value
|
||||
|
||||
@@ -3000,9 +3005,8 @@ def validate_source_shorthand(value):
|
||||
except Invalid:
|
||||
pass
|
||||
# Regex for GitHub repo name with optional branch/tag
|
||||
# Note: git allows other branch/tag names as well, but never seen them used before
|
||||
m = re.match(
|
||||
r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))",
|
||||
m = re.fullmatch(
|
||||
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
|
||||
value,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
+3
-3
@@ -37,9 +37,9 @@ class ErrorFormat(StrEnum):
|
||||
|
||||
|
||||
# Toolchains that drive their build natively and never read platformio.ini.
|
||||
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
|
||||
# platformio_options.
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST})
|
||||
NATIVE_TOOLCHAINS = frozenset(
|
||||
{Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST, Toolchain.SDK_NRF}
|
||||
)
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -1171,12 +1171,14 @@ class EsphomeCore:
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
# No warning for using_toolchain_arduino: the native ESP8266 build
|
||||
# honors build_unflags (token-level, matching PlatformIO).
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
if self.using_toolchain_esp_idf or self.using_toolchain_sdk_nrf:
|
||||
# The native ESP-IDF and nRF52 build generators do not consume
|
||||
# build_unflags
|
||||
_LOGGER.warning(
|
||||
"Build unflag %s is ignored when building with the native "
|
||||
"ESP-IDF toolchain",
|
||||
"'%s' toolchain",
|
||||
build_unflag,
|
||||
self.toolchain.value,
|
||||
)
|
||||
self.build_unflags.add(build_unflag)
|
||||
_LOGGER.debug("Adding build unflag: %s", build_unflag)
|
||||
|
||||
@@ -910,7 +910,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
CORE.add_job(_add_automations, config)
|
||||
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
cg.add_build_flag("-fno-exceptions")
|
||||
|
||||
# Libraries
|
||||
for lib in config[CONF_LIBRARIES]:
|
||||
|
||||
@@ -190,6 +190,8 @@
|
||||
#define USE_MICS_5524_DATASHEET
|
||||
#define USE_MICRONOVA_WRITER
|
||||
#define MK2PVROUTER_LISTENER_COUNT 1
|
||||
#define MODBUS_ON_REQUEST_COUNT 1
|
||||
#define MODBUS_ON_RESPONSE_COUNT 1
|
||||
#define NFC_TAG_LISTENER_COUNT 1
|
||||
#define PN532_BINARY_SENSOR_COUNT 1
|
||||
#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1
|
||||
@@ -536,6 +538,8 @@
|
||||
#define USE_SOCKET_TCP_CLIENT_LINK
|
||||
#define USE_SOCKET_TCP_LISTENER
|
||||
#define USE_LWIP_FAST_SELECT
|
||||
#define USE_VNC_INPUT
|
||||
#define USE_VNC_TOUCHSCREEN
|
||||
|
||||
#define USE_SPEAKER
|
||||
#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF
|
||||
|
||||
@@ -42,20 +42,26 @@ _MIRROR_ATTEMPTS = 3
|
||||
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def get_project_link_flags() -> list[str]:
|
||||
"""Return the sorted -Wl, linker flags from the current build."""
|
||||
def get_project_link_flags(flags: Iterable[str] | None = None) -> list[str]:
|
||||
"""Return the -Wl, linker flags from ``flags`` in their given order
|
||||
(default: the current build's flags, sorted)."""
|
||||
from esphome.core import CORE # local import to avoid circular dependency
|
||||
|
||||
return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))
|
||||
if flags is None:
|
||||
flags = sorted(CORE.build_flags)
|
||||
return [flag for flag in flags if flag.startswith("-Wl,")]
|
||||
|
||||
|
||||
def get_project_compile_flags() -> list[str]:
|
||||
"""Return the sorted -D and -W (non-linker) flags from the current build."""
|
||||
def get_project_compile_flags(flags: Iterable[str] | None = None) -> list[str]:
|
||||
"""Return the -D and -W (non-linker) flags from ``flags`` in their given
|
||||
order (default: the current build's flags, sorted)."""
|
||||
from esphome.core import CORE # local import to avoid circular dependency
|
||||
|
||||
if flags is None:
|
||||
flags = sorted(CORE.build_flags)
|
||||
return [
|
||||
flag
|
||||
for flag in sorted(CORE.build_flags)
|
||||
for flag in flags
|
||||
if flag.startswith("-D")
|
||||
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
|
||||
]
|
||||
|
||||
+5
-5
@@ -718,9 +718,9 @@ def _clone_or_update_locked(
|
||||
# compute the same cache key as this one.
|
||||
original_url = url
|
||||
if username is not None and password is not None:
|
||||
url = url.replace(
|
||||
"://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
|
||||
)
|
||||
user = urllib.parse.quote(username, safe="")
|
||||
token = urllib.parse.quote(password, safe="")
|
||||
url = url.replace("://", f"://{user}:{token}@")
|
||||
|
||||
hash_dir_name = _compute_destination_path(key, domain).name
|
||||
repo_dir = _repo_entry_dir(key, domain, subpath)
|
||||
@@ -1051,8 +1051,8 @@ class GitFile:
|
||||
"""Parse a git shorthand URL into its components."""
|
||||
if not isinstance(shorthand, str):
|
||||
raise ValueError("Git shorthand must be a string")
|
||||
m = re.match(
|
||||
r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
|
||||
m = re.fullmatch(
|
||||
rf"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>{cv.GIT_REF_PATTERN}))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
|
||||
shorthand,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.3"
|
||||
esphome/dlms_parser:
|
||||
version: 2.2.0
|
||||
version: 2.2.2
|
||||
esphome/esp-audio-libs:
|
||||
version: 4.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
|
||||
+7
-2
@@ -284,12 +284,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
|
||||
_COMPONENT_CACHE[domain] = manif
|
||||
return manif
|
||||
|
||||
module_name = f"esphome.components.{domain}"
|
||||
try:
|
||||
module = importlib.import_module(f"esphome.components.{domain}")
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
if exception:
|
||||
raise
|
||||
if "No module named" in str(e):
|
||||
if (
|
||||
isinstance(e, ModuleNotFoundError)
|
||||
and e.name is not None
|
||||
and f"{module_name}.".startswith(f"{e.name}.")
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Unable to import component %s: %s", domain, str(e), exc_info=False
|
||||
)
|
||||
|
||||
@@ -1036,8 +1036,15 @@ class ESPHomeLoaderMixin:
|
||||
) -> OrderedDict[str, dict[str, Any]]:
|
||||
files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
|
||||
mapping = OrderedDict()
|
||||
paths: dict[str, Path] = {}
|
||||
for fname in files:
|
||||
filename = fname.stem
|
||||
if (first := paths.setdefault(filename, fname)) != fname:
|
||||
raise yaml.MarkedYAMLError(
|
||||
f"!include_dir_named: {first} and {fname} both use the key "
|
||||
f"'{filename}'; rename one of them",
|
||||
node.start_mark,
|
||||
)
|
||||
mapping[filename] = self.yaml_loader(fname)
|
||||
return mapping
|
||||
|
||||
@@ -1349,8 +1356,8 @@ def _is_file_valid(name: str) -> bool:
|
||||
def find_files(directory: Path, pattern: str) -> Iterator[Path]:
|
||||
"""Recursively find files in a directory matching *pattern*, skipping hidden entries."""
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if _is_file_valid(d)]
|
||||
for f in files:
|
||||
dirs[:] = sorted(d for d in dirs if _is_file_valid(d))
|
||||
for f in sorted(files):
|
||||
filename = Path(f)
|
||||
if _is_file_valid(f) and filename.match(pattern):
|
||||
filename = Path(root) / filename
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ build_unflags =
|
||||
; esp32 envs (which would double-include them).
|
||||
[common:idf-component-libs]
|
||||
lib_deps =
|
||||
esphome/dlms_parser@2.2.0 ; dlms_meter
|
||||
esphome/dlms_parser@2.2.2 ; dlms_meter
|
||||
bblanchon/ArduinoJson@7.4.3 ; json
|
||||
lvgl/lvgl@9.6.0 ; lvgl
|
||||
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
# cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there.
|
||||
# Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs.
|
||||
cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64"
|
||||
cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
cryptography==50.0.2; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
voluptuous==0.16.0
|
||||
PyYAML==6.0.3
|
||||
paho-mqtt==2.1.0
|
||||
colorama==0.4.6
|
||||
tzlocal==5.4.4 # from time
|
||||
tzlocal==5.4.5 # from time
|
||||
tzdata>=2026.5 # from time
|
||||
pyserial==3.5
|
||||
platformio==6.2.0
|
||||
esptool==5.6.0
|
||||
click==8.5.0
|
||||
aioesphomeapi==46.22.0
|
||||
aioesphomeapi==46.23.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.151.5
|
||||
puremagic==2.2.0
|
||||
@@ -28,7 +28,7 @@ bleak==3.0.2
|
||||
smpclient==7.3.0
|
||||
# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only).
|
||||
smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
|
||||
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
requests==2.34.2
|
||||
py7zr==1.1.4
|
||||
platformdirs==4.13.5 # native esp-idf toolchain global cache dir
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pylint==4.1.3
|
||||
ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
ruff==0.17.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
prek==0.5.6 # .github/workflows/ci.yml reads this pin
|
||||
prek==0.5.7 # .github/workflows/ci.yml reads this pin
|
||||
yamlrocks==0.7.1 # used by script/sync_dependency_versions.py
|
||||
|
||||
# Unit tests
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the show_test_card and update_interval check in the display schema."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.display import FULL_DISPLAY_SCHEMA, _validate_test_card
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
WARNING = "`show_test_card: True` is used with `update_interval: never`"
|
||||
|
||||
|
||||
def test_test_card_with_update_interval_never_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The test card is only drawn when something else updates the display."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
FULL_DISPLAY_SCHEMA({"show_test_card": True, "update_interval": "never"})
|
||||
assert WARNING in caplog.text
|
||||
|
||||
|
||||
def test_warning_names_the_display(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""With several displays the warning says which one it is about."""
|
||||
config = {
|
||||
"id": ID("status_epaper", is_declaration=True),
|
||||
"show_test_card": True,
|
||||
"update_interval": cv.update_interval("never"),
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
_validate_test_card(config)
|
||||
assert f"status_epaper: {WARNING}" in caplog.text
|
||||
|
||||
|
||||
def test_warning_without_display_id_has_no_prefix(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A generated id is unset at validation time, so the warning starts with the message."""
|
||||
config = {
|
||||
"id": ID(None, is_declaration=True),
|
||||
"show_test_card": True,
|
||||
"update_interval": cv.update_interval("never"),
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
_validate_test_card(config)
|
||||
assert WARNING in caplog.text
|
||||
assert f": {WARNING}" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
pytest.param(
|
||||
{"show_test_card": True, "update_interval": "1s"}, id="test_card_1s"
|
||||
),
|
||||
pytest.param({"show_test_card": True}, id="test_card_default_interval"),
|
||||
pytest.param(
|
||||
{"show_test_card": False, "update_interval": "never"},
|
||||
id="no_test_card_never",
|
||||
),
|
||||
pytest.param({"update_interval": "never"}, id="never_only"),
|
||||
],
|
||||
)
|
||||
def test_other_combinations_do_not_warn(
|
||||
config: ConfigType, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Only the test card together with `update_interval: never` warns."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
FULL_DISPLAY_SCHEMA(config)
|
||||
assert WARNING not in caplog.text
|
||||
|
||||
|
||||
def test_test_card_without_update_interval_accepted(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Platforms such as epaper_spi may leave update_interval unset at this stage."""
|
||||
config = {"show_test_card": True}
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.components.display"):
|
||||
assert _validate_test_card(config) is config
|
||||
assert WARNING not in caplog.text
|
||||
@@ -138,3 +138,26 @@ def test_task_log_buffer_size_unaligned_allowed_on_libretiny(
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
assert validate_task_log_buffer_alignment(641) == 641
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "log_v2"),
|
||||
[
|
||||
pytest.param("test_logger_idf6_esp_idf.yaml", True, id="native"),
|
||||
pytest.param("test_logger_idf6_platformio.yaml", False, id="platformio"),
|
||||
],
|
||||
)
|
||||
def test_log_v2_only_on_native_toolchain(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
config: str,
|
||||
log_v2: bool,
|
||||
) -> None:
|
||||
"""PlatformIO links the bootloader with the app's build flags, so the wrap has no target there."""
|
||||
from esphome.components.esp32 import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
|
||||
generate_main(f"tests/component_tests/logger/{config}")
|
||||
|
||||
assert ("-Wl,--wrap=esp_log_format" in CORE.build_flags) is log_v2
|
||||
assert ("USE_ESP32_LOG_V2" in {d.name for d in CORE.defines}) is log_v2
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert ("CONFIG_LOG_VERSION_2" in sdkconfig) is log_v2
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
toolchain: esp-idf
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: 6.1.0
|
||||
|
||||
logger:
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
toolchain: platformio
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: 6.1.0
|
||||
platform_version: 61.4.0-RC1
|
||||
|
||||
logger:
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user