Compare commits

...

25 Commits

Author SHA1 Message Date
Clyde Stubbs 8ccf0dbd37 [gsl3670] Add new touchscreen component (#16285) 2026-07-09 12:04:43 +12:00
Jesse Hills 7c130fc970 [core] Hide build & framework internals from the visual editor (#17449) 2026-07-09 11:58:40 +12:00
Clyde Stubbs 640e0973ac [lvgl] Dynamic rotation features (#16773) 2026-07-08 15:54:28 -04:00
dependabot[bot] 9088875491 Bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#17456)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 15:42:48 -04:00
dependabot[bot] 99ec2cc00a Bump CodSpeedHQ/action from 4.18.2 to 4.18.4 (#17455)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 15:42:38 -04:00
dependabot[bot] 99ff7e198a Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 (#17454)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 15:42:18 -04:00
dependabot[bot] d4bb20d34b Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#17453)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 15:42:05 -04:00
dependabot[bot] 2db001710c Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 in /.github/actions/restore-python (#17452)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 15:41:50 -04:00
Jesse Hills dd0d0942f5 [image] Restructure into a platform component (#17416) 2026-07-09 07:38:58 +12:00
Jonathan Swoboda 26f48ee9ea [lvgl] Fix ImageValidator.process signature to match base (#17451) 2026-07-09 07:21:23 +12:00
luar123 84f4fbeaa8 [zigbee] Allow to combine and merge endpoints on esp32 (#17402) 2026-07-08 14:41:36 -04:00
Clyde Stubbs ce46895270 [uart][usb_uart] Implement runtime settings update (#16990)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-07-08 13:30:49 -04:00
esphome[bot] e7933a5387 Bump bundled esphome-device-builder to 1.3.1 (#17450)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-08 13:06:38 -04:00
Clyde Stubbs b787281388 [lvgl] Add direct use of mapping (#15863) 2026-07-08 13:00:42 -04:00
Clyde Stubbs bba3a9657b [lvgl] Add animations (#16796)
Co-authored-by: clydeps <U5yx99dok9>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-08 12:17:57 -04:00
Kevin Ahrendt 2f5465c0e8 [sendspin] Suppress WiFi roam scanning while playing (#17133) 2026-07-08 12:07:42 -04:00
Keith Burzinski 9c40ed5d71 [provisioning] Add provisioning window (#17152)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:01:20 -05:00
esphome[bot] 93bc02b308 Bump bundled esphome-device-builder to 1.3.0 (#17448)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-07 20:39:55 -05:00
Jesse Hills b8af90750f [web_server_idf] Map more common HTTP status codes in responses (#17447) 2026-07-08 13:18:46 +12:00
Jesse Hills 65ef05dd1f [web_server_idf] Deliver raw POST bodies to custom handlers via handleBody() (#17433) 2026-07-07 20:51:00 -04:00
Bonne Eggleston 731486d9b0 [modbus] Finalize unreleased API surface before 2026.7 (#17434)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:20:14 +00:00
Jonathan Swoboda 731e9fda03 [internal_temperature] Support all ESP32 variants with a temperature sensor (#17438) 2026-07-08 09:12:57 +12:00
dependabot[bot] 35c7496cd7 Bump CodSpeedHQ/action from 4.18.1 to 4.18.2 (#17445)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 16:02:38 -05:00
dependabot[bot] 71b746b6fa Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 (#17444)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 16:02:26 -05:00
dependabot[bot] 1913818b1c Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 in /.github/actions/restore-python (#17442)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 16:02:16 -05:00
141 changed files with 6081 additions and 1630 deletions
+2
View File
@@ -1,3 +1,5 @@
# Normalize line endings to LF in the repository
* text eol=lf
*.png binary
*.gif binary
*.apng binary
+1 -1
View File
@@ -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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- 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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+4 -4
View File
@@ -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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -171,7 +171,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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -372,7 +372,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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -456,7 +456,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4
with:
run: |
. venv/bin/activate
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+3
View File
@@ -187,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/fs3000/* @kahrendt
@@ -208,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte
esphome/components/gsl3670/* @clydebarrow
esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
@@ -404,6 +406,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1
RUN \
platformio settings set enable_telemetry No \
+20 -98
View File
@@ -1,114 +1,36 @@
import logging
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
# 2027.1.0.
#
# Animations are now a platform of the `image:` component (`platform:
# animation`); the real schema, actions and codegen live in `image.py`. This
# module only keeps the deprecated top-level `animation:` key working during the
# deprecation window: it reuses that schema/codegen and adds a one-shot
# deprecation warning (with a pasteable migrated `image:` block) at validation
# time. Deleting this file drops the top-level form entirely.
# ---------------------------------------------------------------------------
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
import esphome.components.image as espImage
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
_LOGGER = logging.getLogger(__name__)
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
AUTO_LOAD = ["image"]
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
MULTI_CONF = True
MULTI_CONF_NO_DEFAULT = True
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
DOMAIN = "animation"
animation_ns = cg.esphome_ns.namespace("animation")
LEGACY_REMOVAL_VERSION = "2027.1.0"
Animation_ = animation_ns.class_("Animation", espImage.Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
_capture_legacy_entry, _warn_legacy_animation = (
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
)
CONFIG_SCHEMA = cv.All(
espImage.IMAGE_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(Animation_),
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
),
espImage.validate_settings,
)
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def to_code(config):
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await espImage.write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
to_code = setup_animation
+115
View File
@@ -0,0 +1,115 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
animation_ns = cg.esphome_ns.namespace("animation")
Animation_ = animation_ns.class_("Animation", Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
)
ANIMATION_SCHEMA = image_schema(Animation_).extend(
{
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
)
# Shared schema used by both the (deprecated) top-level `animation:` key and the
# `image:` `platform: animation` entry.
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def setup_animation(config: ConfigType) -> None:
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
to_code = setup_animation
+18
View File
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register the API as a provisioning source when encryption is enabled.
With no ``key`` the device boots unprovisioned and is set up on first
connection; a YAML ``key`` means it is born provisioned. Either way the API
drives the provisioning manager, so it counts as a source for `provisioning:`.
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
"""
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
from esphome.components import provisioning
provisioning.register_source("api")
if CONF_KEY in encryption:
provisioning.report_hardcoded_credentials("api")
return config
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
@@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
_register_provisioning_source,
)
+14
View File
@@ -158,6 +158,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
// Reason a party is requesting the connection be closed.
enum DisconnectReason {
// No specific reason / not provided (default for older peers).
DISCONNECT_REASON_UNSPECIFIED = 0;
// The device's provisioning window has expired. The device must be reset
// (power-cycled) to reopen the provisioning window before it will accept a
// connection again.
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
}
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +176,10 @@ message DisconnectRequest {
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
// Optional reason the connection is being closed. Older peers that do not
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
DisconnectReason reason = 1;
}
message DisconnectResponse {
+27 -1
View File
@@ -25,6 +25,9 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -1724,6 +1727,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
// The provisioning window has closed without the device being provisioned.
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
}
#endif
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1874,7 +1890,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
void APIConnection::on_disconnect_request() {
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
// The reason is informational when a client disconnects us; we always ack and close.
if (!this->send_disconnect_response_()) {
this->on_fatal_error();
}
@@ -2002,6 +2019,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
#ifdef USE_PROVISIONING
// Refuse to set a key once the provisioning window has closed (defense in depth;
// such connections are already rejected at hello).
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
return this->send_message(resp);
}
#endif
psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
+1 -1
View File
@@ -259,7 +259,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
void on_disconnect_request();
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
+20
View File
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->reason = static_cast<enums::DisconnectReason>(value);
break;
default:
return false;
}
return true;
}
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
return pos;
}
uint32_t DisconnectRequest::calculate_size() const {
uint32_t size = 0;
size += this->reason ? 2 : 0;
return size;
}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
+10 -2
View File
@@ -11,6 +11,10 @@ namespace esphome::api {
namespace enums {
enum DisconnectReason : uint32_t {
DISCONNECT_REASON_UNSPECIFIED = 0,
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
protected:
};
class DisconnectRequest final : public ProtoMessage {
class DisconnectRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 5;
static constexpr uint8_t ESTIMATED_SIZE = 0;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
enums::DisconnectReason reason{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
+12 -1
View File
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
switch (value) {
case enums::DISCONNECT_REASON_UNSPECIFIED:
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
+4 -2
View File
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
DisconnectRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_request"));
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
this->on_disconnect_request();
this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
+1 -1
View File
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
void on_hello_request(const HelloRequest &value){};
void on_disconnect_request(){};
void on_disconnect_request(const DisconnectRequest &value){};
void on_disconnect_response(){};
void on_ping_request(){};
void on_ping_response(){};
+51 -10
View File
@@ -107,8 +107,30 @@ void APIServer::setup() {
// Initialize last_connected_ for reboot timeout tracking
this->last_connected_ = App.get_loop_component_start_time();
// Set warning status if reboot timeout is enabled
if (this->reboot_timeout_ != 0) {
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Register with the provisioning manager (provisioning:) as a source and
// report our current state (provisioned == an encryption key is set). When the
// window closes, disconnect any client still attempting to provision so it learns
// the reason. The manager owns the timeout, window state and on_timeout automation.
if (provisioning::global_provisioning_manager != nullptr) {
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
this->noise_ctx_.has_psk());
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
}
});
}
#endif
// Set warning status if reboot timeout is enabled (suppressed while provisioning
// is pending so the device waits to be onboarded instead of rebooting).
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
}
@@ -121,8 +143,10 @@ void APIServer::loop() {
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time)
if (this->reboot_timeout_ != 0) {
// (cancelled scheduler items sit in heap memory until their scheduled time).
// Suppressed while a provisioning window is pending so the device waits to be
// onboarded / reset instead of rebooting itself; resumes once provisioned.
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
// (suppressed while provisioning is pending - see loop()).
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -572,8 +597,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The device now has a key; report provisioned so the provisioning window is
// satisfied and the reboot timeout resumes normal operation.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
}
#endif
return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
@@ -584,8 +617,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
return false;
#else
SavedNoisePsk empty_psk{};
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
// provisioning window.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
}
#endif
return result;
#endif
}
#endif
+20 -1
View File
@@ -14,6 +14,9 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -255,6 +258,19 @@ class APIServer final : public Component,
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
#ifdef USE_PROVISIONING
// True while a configured provisioning window is still pending (the device is
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
// not auto-rebooted while waiting to be provisioned. False when no provisioning
// window is configured.
bool provisioning_pending_() const {
return provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
}
#else
bool provisioning_pending_() const { return false; }
#endif
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
bool make_active);
@@ -332,7 +348,10 @@ class APIServer final : public Component,
uint8_t listen_backlog_{4};
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
// 7 bytes used, 1 byte padding
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
+18 -10
View File
@@ -1578,16 +1578,20 @@ FRAMEWORK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO),
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_RELEASE): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version,
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict
},
cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict,
cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict,
cv.Optional(
CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY
): _parse_pio_platform_version,
cv.Optional(
CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY
): {cv.string_strict: cv.string_strict},
cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of(
*LOG_LEVELS_IDF, upper=True
),
cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
cv.Optional(
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
): cv.Schema(
{
cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of(
*ASSERTION_LEVELS, upper=True
@@ -1677,7 +1681,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
cv.Optional(
CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(
cv.All(
cv.Any(
cv.All(cv.string_strict, _parse_idf_component),
@@ -1777,7 +1783,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
*FLASH_FREQUENCIES, upper=True
),
cv.Optional(CONF_PARTITIONS): cv.Any(
cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any(
cv.file_,
cv.ensure_list(
cv.All(
@@ -1801,7 +1807,9 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
cv.Optional(
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
): _validate_toolchain,
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
@@ -7,6 +7,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_ESP32
namespace esphome::esp32_improv {
@@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() {
#endif
global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
ESP_LOGD(TAG, "Provisioning window closed; stopping Improv");
this->stop();
});
}
#endif
// Start with loop disabled - will be enabled by start() when needed
this->disable_loop();
}
@@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() {
if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
return;
#ifdef USE_PROVISIONING
// Don't (re)start advertising once the provisioning window has closed - e.g. when
// wifi tries to restart Improv after the window expired at runtime.
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGD(TAG, "Provisioning window closed; not starting Improv");
return;
}
#endif
ESP_LOGD(TAG, "Setting Improv to start");
this->should_start_ = true;
this->enable_loop();
@@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() {
this->incoming_data_.clear();
return;
}
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning window closed; refusing settings");
this->set_error_(improv::ERROR_NOT_AUTHORIZED);
this->incoming_data_.clear();
return;
}
#endif
if (wifi::global_wifi_component->is_disabled()) {
// Wi-Fi is disabled, so we can't provision. Respond immediately
// instead of letting the client wait out its provisioning timeout.
+6 -2
View File
@@ -202,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(
CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY
): cv.string_strict,
cv.Optional(
CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY
): _parse_platform_version,
}
),
_arduino_check_versions,
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@esphome/core"]
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import contextlib
import hashlib
import io
import logging
from pathlib import Path
import re
from PIL import Image, UnidentifiedImageError
from esphome import core, external_files
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
CONF_INVERT_ALPHA,
CONF_OPAQUE,
CONF_TRANSPARENCY,
DOMAIN,
IMAGE_TYPE,
Image_,
ImageEncoder,
add_metadata,
get_image_type_enum,
get_transparency_enum,
is_svg_file,
validate_settings,
validate_transparency,
validate_type,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_DITHER,
CONF_FILE,
CONF_ICON,
CONF_ID,
CONF_PATH,
CONF_RAW_DATA_ID,
CONF_RESIZE,
CONF_SOURCE,
CONF_TYPE,
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
# If the MDI file cannot be downloaded within this time, abort.
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
SOURCE_LOCAL = "local"
SOURCE_WEB = "web"
SOURCE_MDI = "mdi"
SOURCE_MDIL = "mdil"
SOURCE_MEMORY = "memory"
MDI_SOURCES = {
SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/",
SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/",
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
}
def compute_local_image_path(value) -> Path:
url = value[CONF_URL] if isinstance(value, dict) else value
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
# Downloaded files are cached under the shared `image` domain directory so
# the cache location is unaffected by which platform requested the file.
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
def local_path(value):
value = value[CONF_PATH] if isinstance(value, dict) else value
return str(CORE.relative_config_path(value))
def download_file(url, path):
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
return str(path)
def download_gh_svg(value, source):
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
path = base_dir / f"{mdi_id}.svg"
url = MDI_SOURCES[source] + mdi_id + ".svg"
return download_file(url, path)
def download_image(value):
value = value[CONF_URL] if isinstance(value, dict) else value
return download_file(value, compute_local_image_path(value))
def validate_file_shorthand(value):
value = cv.string_strict(value)
parts = value.strip().split(":")
if len(parts) == 2 and parts[0] in MDI_SOURCES:
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
if match is None:
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
if value.startswith(("http://", "https://")):
return download_image(value)
value = cv.file_(value)
return local_path(value)
LOCAL_SCHEMA = cv.All(
{
cv.Required(CONF_PATH): cv.file_,
},
local_path,
)
def mdi_schema(source):
def validate_mdi(value):
return download_gh_svg(value, source)
return cv.All(
cv.Schema(
{
cv.Required(CONF_ICON): cv.string,
}
),
validate_mdi,
)
WEB_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.string,
},
download_image,
)
TYPED_FILE_SCHEMA = cv.typed_schema(
{
SOURCE_LOCAL: LOCAL_SCHEMA,
SOURCE_WEB: WEB_SCHEMA,
}
| {source: mdi_schema(source) for source in MDI_SOURCES},
key=CONF_SOURCE,
)
OPTIONS_SCHEMA = {
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
"NONE", "FLOYDSTEINBERG", upper=True
),
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
def image_schema(class_: MockObjClass = Image_) -> cv.Schema:
"""Build the validation schema for a single file-backed image entry.
Shared by the built-in ``file`` image platform and the ``animation``
platform (which extends it). Platforms that source their pixels elsewhere
(e.g. ``online_image``) provide their own schema instead.
:param class_: The declared C++ class for the generated image instance.
"""
return cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(class_),
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
**OPTIONS_SCHEMA,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
}
)
def validate_image_final(config: ConfigType) -> ConfigType:
"""Per-entry final validation, shared by file-backed image platforms.
For LVGL 9 the default byte order for RGB565 images is little-endian, so
fill in that default when the user did not specify a byte order and warn
when big-endian was explicitly requested.
"""
if byte_order := config.get(CONF_BYTE_ORDER):
if byte_order == "BIG_ENDIAN":
_LOGGER.warning(
"The image '%s' is configured with big-endian byte order, little-endian is expected",
config.get(CONF_FILE),
)
else:
config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
return config
async def new_image(config: ConfigType) -> MockObj:
"""Generate a single file-backed ``image::Image`` instance.
Used by the built-in ``file`` platform; encodes the image data, registers
the C++ variable and records its metadata for other components to consume.
"""
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
var = cg.new_Pvariable(
config[CONF_ID], prog_arr, width, height, image_type, trans_value
)
add_metadata(
config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY]
)
return var
async def write_image(config, all_frames=False):
path = Path(config[CONF_FILE])
if not path.is_file():
raise core.EsphomeError(f"Could not load image file {path}")
resize = config.get(CONF_RESIZE)
try:
if is_svg_file(path):
import resvg_py
resize = resize or (None, None)
image_data = resvg_py.svg_to_bytes(
svg_path=str(path), width=resize[0], height=resize[1], dpi=100
)
# Convert bytes to Pillow Image
image = Image.open(io.BytesIO(image_data))
width, height = image.size
else:
image = Image.open(path)
width, height = image.size
if resize:
# Preserve aspect ratio
new_width_max = min(width, resize[0])
new_height_max = min(height, resize[1])
ratio = min(new_width_max / width, new_height_max / height)
width, height = int(width * ratio), int(height * ratio)
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc
if not resize and (width > 500 or height > 500):
_LOGGER.warning(
'The image "%s" you requested is very big. Please consider'
" using the resize parameter.",
path,
)
dither = (
Image.Dither.NONE
if config[CONF_DITHER] == "NONE"
else Image.Dither.FLOYDSTEINBERG
)
type = config[CONF_TYPE]
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
invert_alpha = config[CONF_INVERT_ALPHA]
frame_count = 1
if all_frames:
with contextlib.suppress(AttributeError):
frame_count = image.n_frames
if frame_count <= 1:
_LOGGER.warning("Image file %s has no animation frames", path)
# Encode each frame with its own encoder and concatenate. This keeps every
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
# correct without needing to know the total frame count.
byte_order = config.get(CONF_BYTE_ORDER)
combined_data: list[int] = []
encoder: ImageEncoder | None = None
for frame_index in range(frame_count):
image.seek(frame_index)
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
if byte_order is not None:
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
pixels = encoder.convert(image.resize((width, height)), path).getdata()
for row in range(height):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
combined_data.extend(encoder.data)
rhs = [HexInt(x) for x in combined_data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
image_type = get_image_type_enum(type)
trans_value = get_transparency_enum(encoder.transparency)
return prog_arr, width, height, image_type, trans_value, frame_count
# The built-in static-image platform: pixels embedded at compile time from a
# local file, a downloaded web image, or a Material Design Icon.
CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings)
FINAL_VALIDATE_SCHEMA = validate_image_final
async def to_code(config: ConfigType) -> None:
await new_image(config)
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@clydebarrow"]
@@ -0,0 +1,167 @@
#include "gsl3670_touchscreen.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome::gsl3670 {
static const char *const TAG = "gsl3670.touchscreen";
static const size_t MAX_TOUCHES = 3;
// ---------------------------------------------------------------------------
// setup() mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP:
// clear_reg → reset → load_fw → startup_chip → reset → startup_chip
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::setup() {
ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen...");
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
this->clear_reg_();
this->reset_();
this->load_firmware_();
this->startup_chip_();
this->reset_();
this->startup_chip_();
ESP_LOGCONFIG(TAG, "GSL3670 initialised OK");
}
void GSL3670Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG,
"GSL3670 Touchscreen:\n"
" X-raw-max: %d\n"
" Y-raw-max: %d\n",
this->x_raw_max_, this->y_raw_max_);
LOG_I2C_DEVICE(this);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_);
}
// ---------------------------------------------------------------------------
// update_touches() mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::update_touches() {
uint8_t buf[44] = {};
auto err = this->read_register(0x80, buf, sizeof(buf));
if (err != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C read failed (%d)", err);
return;
}
uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES);
// Build gsl_touch_info exactly as the Seeed driver does
for (uint8_t j = 0; j != finger_num; j++) {
// buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi
auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]);
auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]);
auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f;
ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y);
if (x <= 8192 && y <= 8192)
this->add_raw_touch_position_(id, x, y);
}
}
// ---------------------------------------------------------------------------
// clear_reg_() mirrors esp_lcd_touch_gsl3670_clear_reg()
// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::clear_reg_() {
ESP_LOGD(TAG, "clear_reg");
// GPIO reset pulse
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0x88, 0x01);
// delay(5);
this->write_reg8_(0xe4, 0x04);
// delay(5);
this->write_reg8_(0xe0, 0x00);
// delay(5);
}
// ---------------------------------------------------------------------------
// reset_() mirrors touch_gsl3670_reset()
// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::reset_() {
ESP_LOGD(TAG, "reset");
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0xe4, 0x04);
uint8_t zeros[4] = {0, 0, 0, 0};
this->write_reg_(0xbc, zeros, 4);
}
void GSL3670Touchscreen::load_firmware_() {
if (firmware_ == nullptr || firmware_len_ == 0) {
ESP_LOGW(TAG, "No firmware supplied skipping");
return;
}
ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_);
static constexpr size_t FW_BLK_SIZE = 128 + 4;
for (size_t i = 0; i != this->firmware_len_; i++) {
auto offset = i * FW_BLK_SIZE;
uint8_t val = this->firmware_[offset + 0];
ESP_LOGV(TAG, "Firmware address 0x%02X", val);
this->write_reg_(0xf0, &val, 1);
this->write_reg_(0, this->firmware_ + offset + 4, 128);
}
ESP_LOGD(TAG, "Firmware load complete");
}
// ---------------------------------------------------------------------------
// startup_chip_() mirrors esp_lcd_touch_gsl3670_startup_chip()
// write 0x00 to 0xe0
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::startup_chip_() {
ESP_LOGD(TAG, "startup_chip");
this->write_reg8_(0xe0, 0x00);
delay(5);
}
// ---------------------------------------------------------------------------
// I2C helpers
// ---------------------------------------------------------------------------
bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) {
auto err = this->write_register(reg, data, len);
if (err != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err);
return false;
}
return true;
}
bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); }
} // namespace esphome::gsl3670
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::gsl3670 {
// ---------------------------------------------------------------------------
// GSL3670 touchscreen ESPHome component
// ---------------------------------------------------------------------------
class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
/// Supply the firmware table (generated by codegen from the YAML)
void set_firmware(const uint8_t *fw, size_t len) {
this->firmware_ = fw;
this->firmware_len_ = len;
}
void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; }
// touchscreen::Touchscreen / Component interface
void setup() override;
void dump_config() override;
protected:
void update_touches() override;
private:
// ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ----------
void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence
void reset_(); // GPIO reset + 0xe4/0xbc sequence
void load_firmware_(); // write GSLX670_FW table
void startup_chip_(); // 0x00→0xe0 + gsl_DataInit
// ---------- I2C helpers ----------
bool write_reg_(uint8_t reg, const uint8_t *data, size_t len);
bool write_reg8_(uint8_t reg, uint8_t val);
InternalGPIOPin *interrupt_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
const uint8_t *firmware_{nullptr};
size_t firmware_len_{0};
};
} // namespace esphome::gsl3670
+209
View File
@@ -0,0 +1,209 @@
"""ESPHome codegen for the gsl3670 touchscreen sub-platform."""
import hashlib
import logging
from pathlib import Path
from esphome import external_files, pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
from esphome.components.const import CONF_SHA256
from esphome.components.touchscreen import (
CONF_X_MAX,
CONF_X_MIN,
CONF_Y_MAX,
CONF_Y_MIN,
option_with_default,
touchscreen_schema,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_ID,
CONF_INTERRUPT_PIN,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_SWAP_XY,
CONF_URL,
)
from esphome.core import ID
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["touchscreen"]
LOGGER = logging.getLogger(__name__)
DOMAIN = "gsl3670"
gsl3670_ns = cg.esphome_ns.namespace("gsl3670")
GSL3670Touchscreen = gsl3670_ns.class_(
"GSL3670Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CONF_FIRMWARE = "firmware"
# Firmware blobs are published as release assets of the companion repository
# rather than vendored into the ESPHome source tree. The default URL/SHA-256
# for each model point at a pinned release artifact; users may override them
# (or supply a local file via `firmware: { file: ... }`).
FIRMWARE_RELEASE = "v1.0.0"
FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}"
MODELS = {
"SEEED-RETERMINAL-D1001": {
CONF_SWAP_XY: True,
CONF_MIRROR_X: True,
CONF_MIRROR_Y: True,
CONF_X_MIN: 20,
CONF_Y_MIN: 20,
CONF_X_MAX: 872,
CONF_Y_MAX: 1644,
CONF_RESET_PIN: {"xl9535": None, "number": 14},
CONF_INTERRUPT_PIN: 16,
CONF_FIRMWARE: {
CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin",
CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4",
},
},
"CUSTOM": {},
}
_FW_BLK_SIZE = 128 + 4
def _validate_firmware_data(data: bytes, source: str) -> None:
"""Validate the structure of a decoded GSL3670 firmware blob."""
blk_cnt = len(data) // _FW_BLK_SIZE
if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data):
raise cv.Invalid(f"Firmware file length is incorrect: {source}")
for i in range(0, len(data), _FW_BLK_SIZE):
if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3:
raise cv.Invalid(
f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}"
)
def _cache_path(url: str) -> Path:
"""Cache path for a downloaded firmware blob, keyed by URL."""
key = hashlib.sha256(url.encode()).hexdigest()[:8]
return external_files.compute_local_file_dir(DOMAIN) / key
def firmware_path(firmware: dict) -> Path:
"""Return the path the firmware bytes will be read from at codegen time."""
if path := firmware.get(CONF_FILE):
return path
return _cache_path(firmware[CONF_URL])
def _validate_firmware(firmware: dict) -> dict:
"""Require a single source, download (with caching), verify and validate."""
if (CONF_FILE in firmware) == (CONF_URL in firmware):
raise cv.Invalid(
f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided"
)
if path := firmware.get(CONF_FILE):
_validate_firmware_data(path.read_bytes(), str(path.absolute()))
return firmware
url = firmware[CONF_URL]
data = external_files.download_content(url, _cache_path(url))
if expected := firmware.get(CONF_SHA256):
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != expected.lower():
raise cv.Invalid(
f"Firmware SHA-256 mismatch for {url}: "
f"expected {expected.lower()}, got {actual}",
[CONF_SHA256],
)
else:
LOGGER.warning(
"No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked"
)
_validate_firmware_data(data, url)
return firmware
FIRMWARE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_URL): cv.url,
cv.Optional(CONF_SHA256): cv.string_strict,
cv.Optional(CONF_FILE): cv.file_,
}
),
_validate_firmware,
)
def _config_schema(config):
model_option = {
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
}
config = cv.Schema(model_option, extra=True)(config)
defaults = MODELS[config[CONF_MODEL]]
schema = (
touchscreen_schema(cv.UNDEFINED, False, defaults)
.extend(
{
cv.GenerateID(): cv.declare_id(GSL3670Touchscreen),
option_with_default(
CONF_INTERRUPT_PIN, defaults
): pins.internal_gpio_input_pin_schema,
option_with_default(
CONF_RESET_PIN, defaults
): pins.gpio_output_pin_schema,
**model_option,
option_with_default(
CONF_FIRMWARE, defaults, required=True
): FIRMWARE_SCHEMA,
}
)
.extend(i2c.i2c_device_schema(0x40))
.extend(cv.COMPONENT_SCHEMA)
)
return schema(config)
CONFIG_SCHEMA = _config_schema
def _read_firmware(config) -> bytes:
path = firmware_path(config[CONF_FIRMWARE])
data = path.read_bytes()
LOGGER.info(
"Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks",
path.absolute(),
len(data),
len(data) // _FW_BLK_SIZE,
)
return data
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
if CONF_INTERRUPT_PIN in config:
pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
cg.add(var.set_interrupt_pin(pin))
if CONF_RESET_PIN in config:
pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
cg.add(var.set_reset_pin(pin))
# Firmware table
data = _read_firmware(config)
fw_array = cg.progmem_array(
ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data)
)
cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE))
+206 -410
View File
@@ -1,38 +1,27 @@
from __future__ import annotations
import contextlib
from collections.abc import Callable
from dataclasses import dataclass
import hashlib
import io
import logging
from pathlib import Path
import re
from PIL import Image, UnidentifiedImageError
from esphome import core, external_files
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA
import esphome.config_validation as cv
from esphome.const import (
CONF_DEFAULTS,
CONF_DITHER,
CONF_FILE,
CONF_ICON,
CONF_ID,
CONF_PATH,
CONF_RAW_DATA_ID,
CONF_RESIZE,
CONF_SOURCE,
CONF_TYPE,
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
from esphome.core import CORE
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "image"
DEPENDENCIES = ["display"]
IS_PLATFORM_COMPONENT = True
# Name of the built-in static-image platform (local file / web / MDI sources).
PLATFORM_FILE = "file"
image_ns = cg.esphome_ns.namespace("image")
@@ -135,17 +124,6 @@ class ImageEncoder:
"""
return False
@classmethod
def get_options(cls) -> list[str]:
"""
Get the available options for this image encoder
"""
options = [*OPTIONS]
if not cls.is_endian():
options.remove(CONF_BYTE_ORDER)
options.append(CONF_RAW_DATA_ID)
return options
def is_alpha_only(image: Image):
"""
@@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType")
CONF_TRANSPARENCY = "transparency"
# If the MDI file cannot be downloaded within this time, abort.
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
SOURCE_LOCAL = "local"
SOURCE_WEB = "web"
SOURCE_MDI = "mdi"
SOURCE_MDIL = "mdil"
SOURCE_MEMORY = "memory"
MDI_SOURCES = {
SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/",
SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/",
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
}
Image_ = image_ns.class_("Image")
INSTANCE_TYPE = Image_
def compute_local_image_path(value) -> Path:
url = value[CONF_URL] if isinstance(value, dict) else value
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
def local_path(value):
value = value[CONF_PATH] if isinstance(value, dict) else value
return str(CORE.relative_config_path(value))
def download_file(url, path):
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
return str(path)
def download_gh_svg(value, source):
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
path = base_dir / f"{mdi_id}.svg"
url = MDI_SOURCES[source] + mdi_id + ".svg"
return download_file(url, path)
def download_image(value):
value = value[CONF_URL] if isinstance(value, dict) else value
return download_file(value, compute_local_image_path(value))
def is_svg_file(file):
if not file:
return False
@@ -399,62 +328,6 @@ def is_svg_file(file):
return "<svg" in str(f.read(1024))
def validate_file_shorthand(value):
value = cv.string_strict(value)
parts = value.strip().split(":")
if len(parts) == 2 and parts[0] in MDI_SOURCES:
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
if match is None:
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
if value.startswith(("http://", "https://")):
return download_image(value)
value = cv.file_(value)
return local_path(value)
LOCAL_SCHEMA = cv.All(
{
cv.Required(CONF_PATH): cv.file_,
},
local_path,
)
def mdi_schema(source):
def validate_mdi(value):
return download_gh_svg(value, source)
return cv.All(
cv.Schema(
{
cv.Required(CONF_ICON): cv.string,
}
),
validate_mdi,
)
WEB_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.string,
},
download_image,
)
TYPED_FILE_SCHEMA = cv.typed_schema(
{
SOURCE_LOCAL: LOCAL_SCHEMA,
SOURCE_WEB: WEB_SCHEMA,
}
| {source: mdi_schema(source) for source in MDI_SOURCES},
key=CONF_SOURCE,
)
def validate_transparency(choices=TRANSPARENCY_TYPES):
def validate(value):
if isinstance(value, bool):
@@ -508,271 +381,6 @@ def validate_settings(value, path=()):
return value
IMAGE_ID_SCHEMA = {
cv.Required(CONF_ID): cv.declare_id(Image_),
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
}
OPTIONS_SCHEMA = {
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
"NONE", "FLOYDSTEINBERG", upper=True
),
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
DEFAULTS_SCHEMA = {
**OPTIONS_SCHEMA,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
}
OPTIONS = [key.schema for key in OPTIONS_SCHEMA]
# image schema with no defaults, used with `CONF_IMAGES` in the config
IMAGE_SCHEMA_NO_DEFAULTS = {
**IMAGE_ID_SCHEMA,
**{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS},
}
IMAGE_SCHEMA = cv.Schema(
{
**IMAGE_ID_SCHEMA,
**OPTIONS_SCHEMA,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
}
)
def apply_defaults(image, defaults, path):
"""
Apply defaults to an image configuration
"""
type = image.get(CONF_TYPE, defaults.get(CONF_TYPE))
if type is None:
raise cv.Invalid(
"Type is required either in the image config or in the defaults", path=path
)
type_class = IMAGE_TYPE[type]
config = {
**{key: image.get(key, defaults.get(key)) for key in type_class.get_options()},
**{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA},
CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)),
}
validate_settings(config, path)
return config
def validate_defaults(value):
"""
Apply defaults to the images in the configuration and flatten to a single list.
"""
defaults = value[CONF_DEFAULTS]
result = []
# Apply defaults to the images: list and add the list entries to the result
for index, image in enumerate(value.get(CONF_IMAGES, [])):
result.append(apply_defaults(image, defaults, [CONF_IMAGES, index]))
# Apply defaults to images under the type keys and add them to the result
for image_type, type_config in value.items():
type_upper = image_type.upper()
if type_upper not in IMAGE_TYPE:
continue
type_class = IMAGE_TYPE[type_upper]
if isinstance(type_config, list):
# If the type is a list, apply defaults to each entry
for index, image in enumerate(type_config):
result.append(apply_defaults(image, defaults, [image_type, index]))
else:
# Handle transparency options for the type
for trans_type in set(type_class.allow_config).intersection(type_config):
for index, image in enumerate(type_config[trans_type]):
result.append(
apply_defaults(image, defaults, [image_type, trans_type, index])
)
return result
def typed_image_schema(image_type):
"""
Construct a schema for a specific image type, allowing transparency options
"""
return cv.Any(
cv.Schema(
{
cv.Optional(t.lower()): cv.ensure_list(
{
**IMAGE_ID_SCHEMA,
**{
cv.Optional(key): OPTIONS_SCHEMA[key]
for key in OPTIONS
if key != CONF_TRANSPARENCY
},
cv.Optional(
CONF_TRANSPARENCY, default=t
): validate_transparency((t,)),
cv.Optional(CONF_TYPE, default=image_type): validate_type(
(image_type,)
),
}
)
for t in IMAGE_TYPE[image_type].allow_config.intersection(
TRANSPARENCY_TYPES
)
}
),
# Allow a default configuration with no transparency preselected
cv.ensure_list(
{
**IMAGE_SCHEMA_NO_DEFAULTS,
cv.Optional(CONF_TYPE, default=image_type): validate_type(
(image_type,)
),
}
),
)
# The config schema can be a (possibly empty) single list of images,
# or a dictionary with optional keys `defaults:`, `images:` and the image types
def _config_schema(value):
if isinstance(value, list) or (
isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value)
):
return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value)
if not isinstance(value, dict):
raise cv.Invalid(
"Badly formed image configuration, expected a list or a dictionary",
)
return cv.All(
cv.Schema(
{
cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA,
cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list(
{
**IMAGE_SCHEMA_NO_DEFAULTS,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
}
),
**{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE},
}
),
validate_defaults,
)(value)
CONFIG_SCHEMA = _config_schema
def _final_validate(config):
"""
For LVGL 9 the default byte order for RGB565 images is little-endian
:param config:
:return:
"""
config = config.copy()
for c in config:
if byte_order := c.get(CONF_BYTE_ORDER):
if byte_order == "BIG_ENDIAN":
_LOGGER.warning(
"The image '%s' is configured with big-endian byte order, little-endian is expected",
c.get(CONF_FILE),
)
else:
c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def write_image(config, all_frames=False):
path = Path(config[CONF_FILE])
if not path.is_file():
raise core.EsphomeError(f"Could not load image file {path}")
resize = config.get(CONF_RESIZE)
try:
if is_svg_file(path):
import resvg_py
resize = resize or (None, None)
image_data = resvg_py.svg_to_bytes(
svg_path=str(path), width=resize[0], height=resize[1], dpi=100
)
# Convert bytes to Pillow Image
image = Image.open(io.BytesIO(image_data))
width, height = image.size
else:
image = Image.open(path)
width, height = image.size
if resize:
# Preserve aspect ratio
new_width_max = min(width, resize[0])
new_height_max = min(height, resize[1])
ratio = min(new_width_max / width, new_height_max / height)
width, height = int(width * ratio), int(height * ratio)
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc
if not resize and (width > 500 or height > 500):
_LOGGER.warning(
'The image "%s" you requested is very big. Please consider'
" using the resize parameter.",
path,
)
dither = (
Image.Dither.NONE
if config[CONF_DITHER] == "NONE"
else Image.Dither.FLOYDSTEINBERG
)
type = config[CONF_TYPE]
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
invert_alpha = config[CONF_INVERT_ALPHA]
frame_count = 1
if all_frames:
with contextlib.suppress(AttributeError):
frame_count = image.n_frames
if frame_count <= 1:
_LOGGER.warning("Image file %s has no animation frames", path)
# Encode each frame with its own encoder and concatenate. This keeps every
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
# correct without needing to know the total frame count.
byte_order = config.get(CONF_BYTE_ORDER)
combined_data: list[int] = []
encoder: ImageEncoder | None = None
for frame_index in range(frame_count):
image.seek(frame_index)
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
if byte_order is not None:
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
pixels = encoder.convert(image.resize((width, height)), path).getdata()
for row in range(height):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
combined_data.extend(encoder.data)
rhs = [HexInt(x) for x in combined_data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
image_type = get_image_type_enum(type)
trans_value = get_transparency_enum(encoder.transparency)
return prog_arr, width, height, image_type, trans_value, frame_count
def add_metadata(id: str, width: int, height: int, image_type: str, transparency):
all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
all_metadata[str(id)] = ImageMetaData(
@@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
# Base platform-component codegen: each entry is generated by its platform's
# own ``to_code``; here we only need the feature define to be present.
cg.add_define("USE_IMAGE")
# By now the config will be a simple list.
for entry in config:
prog_arr, width, height, image_type, trans_value, _ = await write_image(entry)
cg.new_Pvariable(
entry[CONF_ID], prog_arr, width, height, image_type, trans_value
)
add_metadata(
entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY]
)
def get_all_image_metadata() -> dict[str, ImageMetaData]:
@@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]:
def get_image_metadata(image_id: str) -> ImageMetaData | None:
"""Get image metadata by ID for use by other components."""
return get_all_image_metadata().get(image_id)
# ---------------------------------------------------------------------------
# Legacy top-level component -> `image:` platform deprecation helpers
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
#
# `animation:` and `online_image:` used to be standalone top-level components and
# are now platforms of `image:`. Their deprecated top-level shims use this helper
# to (1) record each raw entry as it is validated and (2) print a single,
# pasteable migrated `image:` block once every entry has been seen. The block is
# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry
# CONFIG_SCHEMA step, so all entries are captured before it fires.
# ---------------------------------------------------------------------------
def legacy_platform_migration_warning(
domain: str, platform: str, removal_version: str
) -> tuple[
Callable[[ConfigType], ConfigType],
Callable[[ConfigType], ConfigType],
]:
"""Build the per-entry capture and one-shot warning validators for a
deprecated top-level component that is now an ``image:`` platform.
Returns ``(capture, finalize)``:
* ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real
schema so it sees the raw user entry; it records a copy of each entry.
* ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly
once with the migrated, pasteable ``image:`` block.
"""
entries_key = "legacy_entries"
shown_key = "legacy_warning_shown"
def capture(config: ConfigType) -> ConfigType:
data = CORE.data.setdefault(domain, {})
data.setdefault(entries_key, []).append(dict(config))
return config
def finalize(config: ConfigType) -> ConfigType:
data = CORE.data.setdefault(domain, {})
if not data.get(shown_key):
data[shown_key] = True
from esphome import yaml_util
migrated = [
{CONF_PLATFORM: platform, **entry}
for entry in data.get(entries_key, [])
]
_LOGGER.warning(
"The top-level '%s:' configuration is deprecated and will be "
"removed in ESPHome %s. '%s' is now a platform of the 'image' "
"component. Replace your '%s:' block with:\n\n%s",
domain,
removal_version,
domain,
domain,
yaml_util.dump({DOMAIN: migrated}),
)
return config
return capture, finalize
# ---------------------------------------------------------------------------
# Legacy `image:` config migration -- REMOVE after 2027.1.0
#
# Before `image` became a platform component, its top-level config was either a
# bare list of image dicts, a single image dict, or a dict with `defaults:`,
# `images:` and per-type group keys. This block transparently rewrites those
# forms into the new ``platform: file`` list and prints the migrated YAML.
# It is intentionally self-contained so it can be deleted in one piece together
# with the ``LEGACY_CONFIG_MIGRATE`` assignment below.
# ---------------------------------------------------------------------------
LEGACY_REMOVAL_VERSION = "2027.1.0"
def _is_new_image_format(config: object) -> bool:
"""True when the config is already the new ``platform:``-tagged list."""
return isinstance(config, list) and all(
isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config
)
def _is_legacy_image_format(config: object) -> bool:
"""True when ``config`` matches a shape the pre-platform schema accepted.
Only these shapes are migrated. Anything else -- a list containing a
non-dict (or already platform-tagged) entry, or a dict with no recognised
image keys -- is left untouched so the platform validation surfaces a
proper error instead of the migration silently dropping the input.
"""
if isinstance(config, list):
# A bare list of (not-yet-platform-tagged) image dicts.
return bool(config) and all(
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
)
if not isinstance(config, dict):
return False
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
return (
CONF_ID in config
or CONF_FILE in config
or any(
key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE
for key in config
)
)
def _flatten_legacy_image_config(config: object) -> list[dict]:
"""Structurally flatten a legacy ``image:`` config into image dicts.
No validation or file IO is performed -- the ``file`` platform schema
validates the resulting entries. Unrecognised shapes yield no entries so the
normal platform validation surfaces the error.
"""
if isinstance(config, list):
return [dict(entry) for entry in config if isinstance(entry, dict)]
if not isinstance(config, dict):
return []
if CONF_ID in config or CONF_FILE in config:
return [dict(config)]
defaults = config.get(CONF_DEFAULTS) or {}
result: list[dict] = []
def _add(entry: dict, extra: dict) -> None:
merged = {**defaults, **extra, **entry}
# The legacy `defaults:`/type-grouped forms only applied `byte_order` to
# types that support it. Replicate that so an endian default merged into
# e.g. a binary image stays valid.
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
if (
CONF_BYTE_ORDER in merged
and isinstance(type_class, type)
and issubclass(type_class, ImageEncoder)
and not type_class.is_endian()
):
del merged[CONF_BYTE_ORDER]
result.append(merged)
def _add_entries(entries: object, extra: dict) -> None:
# `entries` may be a single image dict or a list of them; non-dict
# members are silently skipped, mirroring the old `ensure_list` leniency.
for entry in [entries] if isinstance(entries, dict) else entries:
if isinstance(entry, dict):
_add(entry, extra)
_add_entries(config.get(CONF_IMAGES, []), {})
for key, value in config.items():
if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE:
continue
type_extra = {CONF_TYPE: key}
if isinstance(value, dict) and (
transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES]
):
for trans in transparency_keys:
_add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans})
elif isinstance(value, (list, dict)):
_add_entries(value, type_extra)
return result
def _migrate_legacy_image_config(config: object) -> list[dict] | None:
"""Rewrite a legacy ``image:`` config into the ``platform: file`` list.
Returns None for the already-migrated platform form and for any shape the
pre-platform schema never accepted, so normal platform validation can
surface a proper error instead of the migration silently discarding input.
"""
if _is_new_image_format(config) or not _is_legacy_image_format(config):
return None
migrated = [
{CONF_PLATFORM: PLATFORM_FILE, **entry}
for entry in _flatten_legacy_image_config(config)
]
from esphome import yaml_util
_LOGGER.warning(
"The 'image:' configuration format is deprecated and will be removed in "
"ESPHome %s. Images are now platforms of the 'image' component. Replace "
"your 'image:' block with:\n\n%s",
LEGACY_REMOVAL_VERSION,
yaml_util.dump({DOMAIN: migrated}),
)
return migrated
LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config
# --------------------------- end legacy migration --------------------------
@@ -3,17 +3,16 @@
#include "esphome/core/log.h"
#include "internal_temperature.h"
#include <soc/soc_caps.h>
#if defined(USE_ESP32_VARIANT_ESP32)
// there is no official API available on the original ESP32
extern "C" {
uint8_t temprature_sens_read();
}
#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \
defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \
defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \
defined(USE_ESP32_VARIANT_ESP32S3)
#elif SOC_TEMP_SENSOR_SUPPORTED
#include "driver/temperature_sensor.h"
#endif // USE_ESP32_VARIANT
#endif
namespace esphome::internal_temperature {
@@ -27,10 +26,7 @@ void InternalTemperatureSensor::update() {
ESP_LOGV(TAG, "Raw temperature value: %d", raw);
temperature = (raw - 32) / 1.8f;
success = (raw != 128);
#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \
defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \
defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \
defined(USE_ESP32_VARIANT_ESP32S3)
#elif SOC_TEMP_SENSOR_SUPPORTED
esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature);
success = (result == ESP_OK);
if (!success) {
@@ -49,9 +45,7 @@ void InternalTemperatureSensor::update() {
}
void InternalTemperatureSensor::setup() {
#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \
defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \
defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3)
#if SOC_TEMP_SENSOR_SUPPORTED
temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80);
esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_);
+4 -1
View File
@@ -257,7 +257,10 @@ FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
# Raw PlatformIO package source — build internal, not a UI field.
cv.Optional(
CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY
): cv.string_strict,
cv.Optional(CONF_LOGLEVEL, default="warn"): (
cv.one_of(*LT_LOGLEVELS, upper=True)
),
+8 -1
View File
@@ -52,9 +52,11 @@ from esphome.writer import clean_build
from esphome.yaml_util import load_yaml
from . import defines as df, lv_validation as lvalid, widgets
from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code
from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
CONF_ANIMATIONS,
LOGGER,
add_lv_use,
get_focused_widgets,
@@ -146,6 +148,8 @@ SIMPLE_TRIGGERS = (
df.CONF_ON_RESUME,
df.CONF_ON_DRAW_START,
df.CONF_ON_DRAW_END,
df.CONF_ON_LANDSCAPE,
df.CONF_ON_PORTRAIT,
)
@@ -435,7 +439,8 @@ async def to_code(configs):
await layers_to_code(lv_component, config)
await lvgl_update(lv_component, config)
await msgboxes_to_code(lv_component, config)
# await disp_update(lv_component.get_disp(), config)
await animations_to_code(config.get(CONF_ANIMATIONS, []))
# Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed.
set_widgets_completed(True)
async with LvContext():
@@ -443,6 +448,7 @@ async def to_code(configs):
await generate_align_tos(configs[0])
for config in configs:
lv_component = await cg.get_variable(config[CONF_ID])
await add_animation_triggers(config.get(CONF_ANIMATIONS, []))
await generate_page_triggers(config)
await initial_focus_to_code(config)
for conf in config.get(CONF_ON_IDLE, ()):
@@ -636,6 +642,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
+197
View File
@@ -0,0 +1,197 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_LVGL_ANIMATION
#include "lvgl_esphome.h"
#include "esphome/core/hal.h"
namespace esphome::lvgl {
enum class AnimationState {
STOPPED,
STARTED,
RUNNING,
};
class LvAnimationTiming {
public:
// Map progress in the range [0, 1]
virtual float map_progress(float value) = 0;
};
class LvAnimationTimingRoundTrip : public LvAnimationTiming {
public:
float map_progress(float value) override {
value *= 2.0f;
if (value > 1.0f)
return 2.0f - value;
return value;
}
};
class LvAnimationTimingGravity : public LvAnimationTiming {
public:
LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {}
float map_progress(float value) override {
if (value == 0.0f) {
this->initial_position_ = 0.0f;
this->initial_speed_ = 0.0f;
this->initial_time_ = 0.0f;
}
auto position = this->calc_pos_(value);
if (position > 1.0f) {
auto initial_time = this->calc_end_time_();
this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_;
this->initial_position_ = 1.0f;
this->initial_time_ = initial_time;
position = calc_pos_(value);
if (position > 1.0f) {
position = 1.0f;
}
}
return position;
}
protected:
float calc_pos_(float value) const {
value -= this->initial_time_;
return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_;
}
float calc_speed_(float value) const {
value -= this->initial_time_;
return this->acceleration_ * value + this->initial_speed_;
}
float calc_end_time_() const {
return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ -
4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) /
this->acceleration_ +
this->initial_time_;
}
float acceleration_;
float bounce_;
float initial_position_{0.0f};
float initial_time_{0.0f};
float initial_speed_{0.0f};
};
class LvAnimationTimingEaseInOut : public LvAnimationTiming {
public:
LvAnimationTimingEaseInOut(float slope) : slope_(slope) {}
float map_progress(float value) override {
float sqr = value * value;
sqr = sqr / (2.0f * (sqr - value) + 1.0f);
return this->slope_ * sqr + (1.0 - this->slope_) * value;
}
protected:
float slope_;
};
template<size_t DATA_SIZE, bool AUTO_START = false> class LvAnimation : public Component {
public:
LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector<TemplatableValue<lv_coord_t>> from,
std::vector<TemplatableValue<lv_coord_t>> to)
: update_callback_(update_callback) {
std::copy(from.begin(), from.end(), this->from_);
std::copy(to.begin(), to.end(), this->to_);
}
void start() {
if (this->state_ > AnimationState::STOPPED)
this->stop();
if (this->duration_ == 0)
return;
// evaluate any lambdas
for (size_t i = 0; i != DATA_SIZE; i++) {
this->data_from_[i] = this->from_[i].value();
this->data_to_[i] = this->to_[i].value();
}
this->start_time_ = millis();
this->state_ = AnimationState::STARTED;
this->loop();
this->start_callback_.call();
}
void stop() {
// Only fire the stop callback on a genuine running -> stopped transition, so that
// repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it.
if (this->state_ == AnimationState::STOPPED)
return;
this->state_ = AnimationState::STOPPED;
this->stop_callback_.call();
}
void setup() override {
if constexpr (AUTO_START)
this->start();
}
void loop() override {
if (this->state_ == AnimationState::STOPPED)
return;
uint32_t elapsed = millis() - this->start_time_;
float progress = static_cast<float>(elapsed) / static_cast<float>(this->duration_);
switch (this->state_) {
case AnimationState::STARTED:
if (elapsed < this->start_delay_)
return;
this->state_ = AnimationState::RUNNING;
this->start_time_ = millis();
progress = 0.0f;
break;
case AnimationState::RUNNING:
if (progress >= 1.0f) {
progress = 1.0f;
this->stop();
if (this->loop_)
this->start();
}
break;
default:
return;
}
for (auto *timing : this->timings_) {
progress = timing->map_progress(progress);
}
lv_coord_t data[DATA_SIZE];
for (size_t i = 0; i != DATA_SIZE; i++) {
data[i] = static_cast<lv_coord_t>(
roundf(this->data_from_[i] + static_cast<lv_coord_t>(this->data_to_[i] - this->data_from_[i]) * progress));
}
this->update_callback_(data);
}
float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; }
void set_duration(uint32_t duration) { this->duration_ = duration; }
void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; }
void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); }
void set_loop(bool loop) { this->loop_ = loop; }
template<typename F> void add_on_start_callback(F &&callback) {
this->start_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward<F>(callback)); }
protected:
void (*const update_callback_)(const lv_coord_t *data);
LazyCallbackManager<void()> start_callback_{};
LazyCallbackManager<void()> stop_callback_{};
TemplatableValue<lv_coord_t> from_[DATA_SIZE]{};
TemplatableValue<lv_coord_t> to_[DATA_SIZE]{};
uint32_t duration_{0};
uint32_t start_delay_{0};
uint32_t start_time_{0};
lv_coord_t data_from_[DATA_SIZE]{0};
lv_coord_t data_to_[DATA_SIZE]{0};
AnimationState state_{AnimationState::STOPPED};
std::vector<LvAnimationTiming *> timings_{};
bool loop_{false};
};
} // namespace esphome::lvgl
#endif // USE_LVGL_ANIMATION
+295
View File
@@ -0,0 +1,295 @@
from esphome import automation, codegen as cg, config_validation as cv
from esphome.automation import Trigger, build_automation
from esphome.config_validation import COMPONENT_SCHEMA
from esphome.const import (
CONF_ACCELERATION,
CONF_DURATION,
CONF_FROM,
CONF_ID,
CONF_ON_START,
CONF_TIMING,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_WEIGHT,
)
from esphome.cpp_generator import MockObj, TemplateArguments
from ..const import CONF_LOOP
from .defines import (
CONF_AUTO_START,
CONF_LVGL_ID,
CONF_ON_STOP,
CONF_WIDGETS,
LValidator,
add_define,
literal,
)
from .lv_validation import (
color,
get_component_colors,
lv_color,
lv_milliseconds,
lv_positive_float,
lv_zero_to_one_float,
)
from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add
from .schemas import STYLE_PROPS
from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns
from .widgets import get_widgets
LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip")
LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut")
CONF_BOUNCE = "bounce"
def timing_class(name, extras=None):
# Convert config option to camel case
cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")])
cls = lvgl_ns.class_(cls_name)
schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)})
if extras:
schema = schema.extend(extras)
return name, schema
# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced.
# It would be better to have a more robust way of passing arguments to the timing classes.
TIMING_SCHEMA = cv.maybe_simple_value(
cv.typed_schema(
dict(
[
timing_class("round_trip"),
timing_class(
"ease_in_out",
{cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float},
),
timing_class(
"gravity",
{
cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float,
cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float,
},
),
]
),
default_type="ease_in_out",
),
key=CONF_TYPE,
)
CONF_START_DELAY = "start_delay"
class LiteralColorValidator(LValidator):
def __init__(self):
super().__init__(
color, lv_color_t, retmapper=get_component_colors, animatable=True
)
def __call__(self, value):
if isinstance(value, cv.Lambda):
raise cv.Invalid(
"An animated color may not be set with a lambda, only a literal color value."
)
return super().__call__(value)
literal_color = LiteralColorValidator()
def from_to(validator):
return cv.Schema(
{
cv.Required(CONF_FROM): validator,
cv.Required(CONF_TO): validator,
}
)
# Colors can only be animated between constants, not lambdas.
def map_v(validator):
if validator == lv_color:
return literal_color
return validator
ANIMABLE_STYLES = {
k: map_v(v)
for k, v in STYLE_PROPS.items()
if isinstance(v, LValidator) and v.animatable
}
ANIMATION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_AUTO_START, default=False): cv.boolean,
cv.Optional(CONF_LOOP, default=False): cv.boolean,
cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds,
cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds,
cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA),
cv.Required(CONF_ID): cv.declare_id(LvAnimation),
cv.Optional(CONF_ON_START): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()),
}
),
cv.Optional(CONF_ON_STOP): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()),
}
),
cv.Required(CONF_WIDGETS): cv.ensure_list(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(lv_obj_t),
}
).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()})
),
}
).extend(COMPONENT_SCHEMA)
async def _process_arg(validator, arg) -> list:
# from/to values are evaluated at animation start with no arguments, so the
# generated lambda must be parameterless rather than inheriting the enclosing
# update-callback's `values` parameter.
value = await validator.process(arg, args=[], raw_lambda=True)
value = list(value) if isinstance(value, tuple) else [value]
return [literal(f"TemplatableValue<lv_coord_t>({v})") for v in value]
async def animations_to_code(config):
for animation in config:
add_define("USE_LVGL_ANIMATION")
widgets = animation[CONF_WIDGETS]
async with LambdaContext(
[(lv_coord_t.operator("const").operator("ptr"), "values")]
) as ctx:
froms = []
tos = []
for widget in widgets:
w = (await get_widgets(widget))[0]
props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES]
for prop, value_range in props:
# prop is the style property, value_range is a dict with from: and to: values
validator = ANIMABLE_STYLES[prop]
from_value = await _process_arg(validator, value_range[CONF_FROM])
to_value = await _process_arg(validator, value_range[CONF_TO])
index = len(froms)
if len(from_value) == 1:
value = f"values[{index}]"
else:
value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])"
w.set_style(prop, literal(value), 0)
# The value arrays are extended by 1 item for scalar properties, 3 for colors
froms.extend(from_value)
tos.extend(to_value)
data_size = len(froms)
loop = animation[CONF_LOOP]
start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY))
var = cg.new_Pvariable(
animation[CONF_ID],
TemplateArguments(data_size, animation[CONF_AUTO_START]),
await ctx.get_lambda(),
froms,
tos,
)
for timing in animation[CONF_TIMING]:
timing_id = timing[CONF_ID]
args = sorted(
[(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]]
)
args = [v for k, v in args]
timing_var = cg.new_Pvariable(timing_id, *args)
cg.add(var.add_timing(timing_var))
if start_delay:
cg.add(var.set_start_delay(start_delay))
if loop:
cg.add(var.set_loop(loop))
cg.add(
var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION]))
)
await cg.register_component(var, animation)
async def add_animation_triggers(config):
async def add_triggers(animation: MockObj, event: str, config: dict) -> None:
for conf in config:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await build_automation(trigger, [], conf)
async with LambdaContext([]) as context:
lv_add(trigger.trigger())
lv_add(
getattr(
animation,
f"add_{event}_callback",
)(await context.get_lambda())
)
for animation in config:
var = await cg.get_variable(animation[CONF_ID])
await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, []))
await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, []))
@automation.register_action(
"lvgl.animation.start",
LvglAction,
cv.maybe_simple_value(
{
cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)),
cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent),
cv.Optional(CONF_DURATION): lv_milliseconds,
cv.Optional(CONF_START_DELAY): lv_milliseconds,
cv.Optional(CONF_LOOP): cv.boolean,
},
key=CONF_ID,
),
synchronous=True,
)
async def start_animation(config, action_id, template_arg, args):
animations = config[CONF_ID]
loop = config.get(CONF_LOOP)
async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
for animation in animations:
anim_var = await cg.get_variable(animation)
if loop is not None:
context.add(anim_var.set_loop(loop))
if (duration := config.get(CONF_DURATION)) is not None:
context.add(
anim_var.set_duration(await lv_milliseconds.process(duration))
)
if (start_delay := config.get(CONF_START_DELAY)) is not None:
context.add(
anim_var.set_start_delay(await lv_milliseconds.process(start_delay))
)
context.add(anim_var.start())
var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
await cg.register_parented(var, config[CONF_LVGL_ID])
return var
@automation.register_action(
"lvgl.animation.stop",
LvglAction,
cv.maybe_simple_value(
{
cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)),
cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent),
},
key=CONF_ID,
),
synchronous=True,
)
async def stop_animation(config, action_id, template_arg, args):
animations = config[CONF_ID]
async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
for animation in animations:
anim_var = await cg.get_variable(animation)
context.add(anim_var.stop())
var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
await cg.register_parented(var, config[CONF_LVGL_ID])
return var
+21 -6
View File
@@ -4,7 +4,6 @@ from typing import Any
from esphome import automation
from esphome.automation import StatelessLambdaAction
import esphome.codegen as cg
from esphome.components.display import validate_rotation
import esphome.config_validation as cv
from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT
from esphome.core import Lambda
@@ -16,6 +15,7 @@ from .defines import (
CONF_BOTTOM_LAYER,
CONF_EDITING,
CONF_FREEZE,
CONF_LAYOUT,
CONF_LVGL_ID,
CONF_MAIN,
CONF_OBJ,
@@ -29,7 +29,8 @@ from .defines import (
get_options,
get_refreshed_widgets,
)
from .lv_validation import lv_bool, lv_milliseconds
from .layout import layout_validator
from .lv_validation import lv_bool, lv_milliseconds, lv_rotation
from .lvcode import (
LVGL_COMP_ARG,
UPDATE_EVENT,
@@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args):
def _validate_rotation(value):
# Note that we need rotation
get_options()[CONF_ROTATION] = True
return validate_rotation(value)
return lv_rotation(value)
@automation.register_action(
@@ -218,7 +219,8 @@ def _validate_rotation(value):
async def lvgl_set_rotation(config, action_id, template_arg, args):
lv_comp = await cg.get_variable(config[CONF_LVGL_ID])
async with LambdaContext(args, where=action_id) as context:
lv_add(lv_comp.set_rotation(config[CONF_ROTATION]))
rotation = await lv_rotation.process(config[CONF_ROTATION])
lv_add(lv_comp.set_rotation(rotation))
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
@@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock
DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
def _layer_update_schema() -> cv.Schema:
"""Schema for updating a display layer's styling and layout options."""
return part_schema(layer_spec.parts).extend(
{cv.Optional(CONF_LAYOUT): layout_validator}
)
@automation.register_action(
"lvgl.update",
LvglAction,
@@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
.extend(DISP_BG_SCHEMA)
.extend(
{
cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts),
cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts),
cv.Optional(CONF_LAYOUT): layout_validator,
cv.Optional(CONF_TOP_LAYER): _layer_update_schema(),
cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(),
}
),
synchronous=True,
@@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config, CONF_LVGL_ID)
w = widgets[0]
async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
# Apply the top-level properties (styles and layout) to the active screen...
await set_obj_properties(get_screen_active(w.var), config)
# ...the deprecated flat `disp_*` background properties...
await lvgl_update(w.var, config)
# ...and the `top_layer`/`bottom_layer` keys (styling and layout updates).
await layers_to_code(w.var, config)
var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
await cg.register_parented(var, w.var)
return var
+21 -6
View File
@@ -214,11 +214,14 @@ class LValidator:
has `process()` to convert a value during code generation
"""
def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None):
def __init__(
self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False
):
self.validator = validator
self.rtype = rtype
self.retmapper = retmapper
self.requires = requires
self.animatable = animatable
def __call__(self, value):
if self.requires:
@@ -228,7 +231,10 @@ class LValidator:
return self.validator(value)
async def process(
self, value: Any, args: list[tuple[SafeExpType, str]] | None = None
self,
value: Any,
args: list[tuple[SafeExpType, str]] | None = None,
raw_lambda: bool = False,
) -> Expression:
if value is None:
return None
@@ -236,11 +242,15 @@ class LValidator:
# Local import to avoid circular import
from .lvcode import get_lambda_context_args
args = args or get_lambda_context_args()
# `args is None` means "inherit the enclosing lambda context"; an explicit
# empty list means "no parameters" and must be preserved as-is.
if args is None:
args = get_lambda_context_args()
return call_lambda(
await cg.process_lambda(value, args, return_type=self.rtype)
)
lamb = await cg.process_lambda(value, args, return_type=self.rtype)
if raw_lambda:
return lamb
return call_lambda(lamb)
if self.retmapper is not None:
return self.retmapper(value)
if isinstance(value, ID):
@@ -729,6 +739,7 @@ CONF_GRID_ROWS = "grid_rows"
CONF_HEADER_BUTTONS = "header_buttons"
CONF_HEADER_MODE = "header_mode"
CONF_HOME = "home"
CONF_IMAGE = "image"
CONF_INDICATORS = "indicators"
CONF_INITIAL_FOCUS = "initial_focus"
CONF_SELECTED_DIGIT = "selected_digit"
@@ -742,15 +753,19 @@ CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time"
CONF_LVGL_ID = "lvgl_id"
CONF_LONG_MODE = "long_mode"
CONF_MAJOR_TICKS_STYLE = "major_ticks_style"
CONF_MAPPING = "mapping"
CONF_MSGBOXES = "msgboxes"
CONF_OBJ = "obj"
CONF_ONE_CHECKED = "one_checked"
CONF_ONE_LINE = "one_line"
CONF_ON_DRAW_START = "on_draw_start"
CONF_ON_DRAW_END = "on_draw_end"
CONF_ON_LANDSCAPE = "on_landscape"
CONF_ON_PAUSE = "on_pause"
CONF_ON_PORTRAIT = "on_portrait"
CONF_ON_RESUME = "on_resume"
CONF_ON_SELECT = "on_select"
CONF_ON_STOP = "on_stop"
CONF_OPA = "opa"
CONF_NEXT = "next"
CONF_PAD_ROW = "pad_row"
+56
View File
@@ -34,6 +34,7 @@ from .defines import (
TYPE_GRID,
TYPE_NONE,
LvConstant,
add_lv_use,
)
from .lv_validation import padding, size
@@ -401,6 +402,61 @@ LAYOUT_CLASSES = (
LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES]
# Layout properties that may be changed at runtime via an update action. These
# are limited to simple style properties (set via ``lv_obj_set_style_...``).
# Structural properties are deliberately excluded:
# - the layout ``type``, which determines which options are available to child
# widgets, and
# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells
# that child widgets are placed into.
# Both are fixed at widget creation.
_GRID_LAYOUT_KEYS = (
CONF_GRID_COLUMN_ALIGN,
CONF_GRID_ROW_ALIGN,
)
_FLEX_LAYOUT_KEYS = (
CONF_FLEX_FLOW,
CONF_FLEX_ALIGN_MAIN,
CONF_FLEX_ALIGN_CROSS,
CONF_FLEX_ALIGN_TRACK,
)
LAYOUT_UPDATE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of,
cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments,
cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of,
cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments,
cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
}
)
def layout_validator(value):
"""
Validate a ``layout:`` value for an update action. Only the layout options
may be changed (not the layout ``type``, which is fixed at widget creation).
:param value: The value of the ``layout:`` key
:return: The validated layout options dict
"""
result = LAYOUT_UPDATE_SCHEMA(value)
if not result:
raise cv.Invalid(
"A layout update must specify at least one layout option", [CONF_LAYOUT]
)
# Register the relevant layout feature so its LV_USE_* define is emitted even
# when the option is set solely via an update action (whose code generation
# may run after LVGL has finished collecting its used features).
if any(key in result for key in _GRID_LAYOUT_KEYS):
add_lv_use(TYPE_GRID)
if any(key in result for key in _FLEX_LAYOUT_KEYS):
add_lv_use(TYPE_FLEX)
return result
def append_layout_schema(schema, config: dict):
"""
Get the child layout schema for a given widget based on its layout type.
+119 -29
View File
@@ -22,9 +22,12 @@ from esphome.helpers import cpp_string_escape
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import Expression, SafeExpType
from ..mapping import INDEX_TYPES, get_mapping_metadata
from . import types as ty
from .defines import (
CONF_END_VALUE,
CONF_IMAGE,
CONF_MAPPING,
CONF_START_VALUE,
CONF_TIME_FORMAT,
LV_FONTS,
@@ -60,6 +63,7 @@ opacity = LValidator(
opacity_validator,
lv_opa_t,
retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0),
animatable=True,
)
COLOR_NAMES = {
@@ -223,35 +227,33 @@ def color(value):
)
def color_retmapper(value):
if isinstance(value, cv.Lambda):
return cv.returning_lambda(value)
def get_component_colors(value):
if isinstance(value, str) and value in COLOR_NAMES:
value = COLOR_NAMES[value]
if isinstance(value, int):
return literal(
f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})"
)
return value >> 16, value >> 8 & 0xFF, value & 0xFF
if isinstance(value, ID):
cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0]
if CONF_HEX in cval:
r, g, b = cval[CONF_HEX]
else:
r, g, b, _ = from_rgbw(cval)
return literal(f"lv_color_make({r}, {g}, {b})")
return r, g, b
raise AssertionError(f"Unhandled lv_color value: {value!r}")
def option_string(value):
value = cv.string(value).strip()
if value.find("\n") != -1:
raise cv.Invalid("Options strings must not contain newlines")
return value
def color_retmapper(value):
if isinstance(value, cv.Lambda):
return cv.returning_lambda(value)
r, g, b = get_component_colors(value)
return literal(f"lv_color_make({r}, {g}, {b})")
class LvColor(LValidator):
def __init__(self):
super().__init__(color, ty.lv_color_t, retmapper=color_retmapper)
super().__init__(
color, ty.lv_color_t, retmapper=color_retmapper, animatable=True
)
def __getattr__(self, item):
if item in COLOR_NAMES:
@@ -262,6 +264,13 @@ class LvColor(LValidator):
lv_color = LvColor()
def option_string(value):
value = cv.string(value).strip()
if value.find("\n") != -1:
raise cv.Invalid("Options strings must not contain newlines")
return value
def pixels_or_percent_validator(value):
"""A length in one axis - either a number (pixels) or a percentage"""
if value == SCHEMA_EXTRACT:
@@ -277,6 +286,7 @@ pixels_or_percent = LValidator(
pixels_or_percent_validator,
lv_coord_t,
retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"),
animatable=True,
)
@@ -315,10 +325,23 @@ def angle(value):
# Validator for angles in LVGL expressed in 1/10 degree units.
lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10))
lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True)
# Validator for angles in LVGL expressed in whole degrees
lv_angle_degrees = LValidator(angle, uint32, retmapper=int)
lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True)
def rotation_degrees(value):
"""Validate a display rotation, returning the angle in whole degrees.
Accepts the four supported rotations, optionally suffixed with "°".
"""
value = cv.string(value).removesuffix("°")
return cv.one_of(0, 90, 180, 270, int=True)(value)
# Validator for a display rotation expressed in whole degrees (templatable)
lv_rotation = LValidator(rotation_degrees, cg.int_)
@schema_extractor("one_of")
@@ -368,21 +391,57 @@ def stop_value(value):
return cv.int_range(0, 255)(value)
def image_validator(value):
value = cv.requires_component("image")(value)
def _image_validator(value):
if isinstance(value, dict) and CONF_MAPPING in value:
from .schemas import MAPPING_IMAGE_SCHEMA
return MAPPING_IMAGE_SCHEMA(value)
value = cv.use_id(Image_)(value)
get_lv_images_used().add(value)
add_lv_use("label")
return value
lv_image = LValidator(
image_validator,
image.Image_.operator("ptr"),
requires="image",
)
class ImageValidator(LValidator):
def __init__(self):
super().__init__(
validator=_image_validator,
rtype=image.Image_.operator("ptr"),
requires=CONF_IMAGE,
)
async def process(
self,
value: Any,
args: list[tuple[SafeExpType, str]] | None = None,
raw_lambda: bool = False,
) -> Expression:
# Local import to avoid circular import at module level
from .lvcode import get_lambda_context_args
args = args or get_lambda_context_args()
if isinstance(value, dict) and CONF_MAPPING in value:
mapping_id = value[CONF_MAPPING]
mapping_var = await cg.get_variable(mapping_id)
metadata = get_mapping_metadata(mapping_id.id)
index = value[CONF_VALUE]
if isinstance(index, Lambda):
index = call_lambda(
await cg.process_lambda(
index, args, return_type=metadata.from_.data_type
)
)
else:
index = await metadata.from_.convert_value(index)
return mapping_var.get(index)
return await super().process(value, args, raw_lambda)
lv_image = ImageValidator()
lv_image_list = LValidator(
cv.ensure_list(image_validator),
cv.ensure_list(_image_validator),
cg.std_vector.template(image.Image_.operator("ptr")),
requires="image",
)
@@ -410,7 +469,10 @@ class TextValidator(LValidator):
return super().__call__(value)
async def process(
self, value: Any, args: list[tuple[SafeExpType, str]] | None = None
self,
value: Any,
args: list[tuple[SafeExpType, str]] | None = None,
raw_lambda: bool = False,
) -> Expression:
# Local import to avoid circular import at module level
from .lvcode import get_lambda_context_args
@@ -430,6 +492,24 @@ class TextValidator(LValidator):
f"(std::isfinite({arg_expr}) ? {sprintf_str} : {nanval})"
)
return literal(sprintf_str)
if mapping_id := value.get(CONF_MAPPING):
mapping_var = await cg.get_variable(mapping_id)
metadata = get_mapping_metadata(mapping_id.id)
if metadata.to_ != INDEX_TYPES["string"]:
raise ValueError(
f"Mapping {mapping_id} does not map to strings, cannot use in text"
)
index = value[CONF_VALUE]
if isinstance(index, Lambda):
index = call_lambda(
await cg.process_lambda(
index, args, return_type=metadata.from_.data_type
)
)
else:
index = await metadata.from_.convert_value(index)
return mapping_var.get(index).c_str()
if time_format := value.get(CONF_TIME_FORMAT):
source = value[CONF_TIME]
if isinstance(source, Lambda):
@@ -455,13 +535,18 @@ class TextValidator(LValidator):
return value
# Either a std::string or a lambda call returning that. We need const char*
return MockObj(f"({value}).c_str()")
return await super().process(value, args)
return await super().process(value, args, raw_lambda)
lv_text = TextValidator()
lv_float = LValidator(cv.float_, cg.float_)
lv_int = LValidator(cv.int_, cg.int_)
lv_positive_int = LValidator(cv.positive_int, cg.int_)
lv_positive_float = LValidator(cv.positive_float, cg.float_)
lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_)
lv_int = LValidator(cv.int_, cg.int_, animatable=True)
lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True)
lv_brightness = LValidator(
cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True
)
def _percentage_validator(value):
@@ -508,12 +593,17 @@ class LvFont(LValidator):
# The inline overloads in lvgl_esphome.h handle conversion to lv_font_t*
super().__init__(validator, Font.operator("ptr"))
async def process(self, value, args=()):
async def process(
self,
value: Any,
args: list[tuple[SafeExpType, str]] | None = None,
raw_lambda: bool = False,
):
if is_lv_font(value):
return literal(f"&lv_font_{value}")
if isinstance(value, str):
return literal(f"{value}")
return await super().process(value, args)
return await super().process(value, args, raw_lambda)
lv_font = LvFont()
+27 -1
View File
@@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) {
this->rotation_ = rotation;
if (this->is_ready()) {
this->set_resolution_();
this->update_orientation_();
lv_obj_update_layout(this->get_screen_active());
lv_obj_invalidate(this->get_screen_active());
}
}
void LvglComponent::set_rotation(int angle) {
// Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees.
angle %= 360;
if (angle < 0)
angle += 360;
if (angle % 90 != 0) {
ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle);
return;
}
this->set_rotation(static_cast<display::DisplayRotation>(angle));
}
void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const {
switch (this->rotation_) {
default:
@@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const {
}
lv_display_set_resolution(this->disp_, width, height);
}
void LvglComponent::update_orientation_() {
// A square display is treated as landscape.
auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT;
if (orientation == this->orientation_)
return;
this->orientation_ = orientation;
auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_;
if (trigger != nullptr)
trigger->trigger();
}
void LvglComponent::setup() {
auto *display = this->displays_[0];
auto rounding = this->draw_rounding;
@@ -757,7 +782,7 @@ void LvglComponent::setup() {
lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this);
lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes,
this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL);
if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) {
if (this->rotation_type_ == ROTATION_SOFTWARE) {
this->rotate_buf_ = static_cast<lv_color_t *>(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT
if (this->rotate_buf_ == nullptr) {
this->status_set_error(LOG_STR("Memory allocation failure"));
@@ -796,6 +821,7 @@ void LvglComponent::setup() {
#endif
this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0);
lv_display_trigger_activity(this->disp_);
this->update_orientation_();
}
void LvglComponent::update() {
+16
View File
@@ -185,6 +185,12 @@ enum RotationType : uint8_t {
ROTATION_HARDWARE,
};
enum class Orientation : uint8_t {
UNKNOWN,
LANDSCAPE,
PORTRAIT,
};
class LvglComponent final : public PollingComponent {
constexpr static const char *const TAG = "lvgl";
@@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent {
void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; }
void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; }
void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; }
void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; }
void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; }
void set_rotation(display::DisplayRotation rotation);
/// Set the rotation from an angle in degrees. Must be a multiple of 90.
void set_rotation(int angle);
display::DisplayRotation get_rotation() const { return this->rotation_; }
void rotate_coordinates(int32_t &x, int32_t &y) const;
@@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent {
protected:
void set_resolution_() const;
// Determine the current orientation from the effective resolution and fire the
// landscape/portrait trigger if it has changed since the last check.
void update_orientation_();
void draw_end_();
// Not checking for non-null callback since the
// LVGL callback that calls it is not set in that case
@@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent {
Trigger<> *resume_callback_{};
Trigger<> *draw_start_callback_{};
Trigger<> *draw_end_callback_{};
Trigger<> *landscape_callback_{};
Trigger<> *portrait_callback_{};
Orientation orientation_{Orientation::UNKNOWN};
void *rotate_buf_{};
display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES};
RotationType rotation_type_;
+21
View File
@@ -3,6 +3,7 @@ from typing import Any
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
from esphome.components.mapping import mapping_class
from esphome.components.time import RealTimeClock
from esphome.config_validation import prepend_path
from esphome.const import (
@@ -17,6 +18,7 @@ from esphome.const import (
CONF_TEXT,
CONF_TIME,
CONF_TRIGGER_ID,
CONF_VALUE,
CONF_X,
CONF_Y,
)
@@ -31,6 +33,7 @@ from esphome.schema_extractors import (
from . import defines as df, lv_validation as lvalid
from .defines import (
CONF_EXT_CLICK_AREA,
CONF_MAPPING,
CONF_SCROLL_DIR,
CONF_SCROLL_SNAP_X,
CONF_SCROLL_SNAP_Y,
@@ -52,6 +55,7 @@ from .layout import (
GRID_CELL_SCHEMA,
append_layout_schema,
grid_alignments,
layout_validator,
)
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
@@ -89,6 +93,20 @@ PRINTF_TEXT_SCHEMA = cv.All(
validate_printf,
)
MAPPING_TEXT_SCHEMA = cv.Schema(
{
cv.Required(CONF_MAPPING): cv.use_id(mapping_class),
cv.Required(CONF_VALUE): cv.templatable(cv.string),
}
)
MAPPING_IMAGE_SCHEMA = cv.Schema(
{
cv.Required(CONF_MAPPING): cv.use_id(mapping_class),
cv.Required(CONF_VALUE): cv.templatable(cv.string),
}
)
def _validate_text(value):
"""
@@ -100,6 +118,8 @@ def _validate_text(value):
if isinstance(value, dict):
if CONF_TIME_FORMAT in value:
return TIME_TEXT_SCHEMA(value)
if CONF_MAPPING in value:
return MAPPING_TEXT_SCHEMA(value)
return PRINTF_TEXT_SCHEMA(value)
return cv.templatable(cv.string)(value)
@@ -504,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
)
),
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
cv.Optional(df.CONF_LAYOUT): layout_validator,
}
)
+1
View File
@@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t")
lv_page_t = LvType("LvPageType", parents=(LvCompound,))
lv_image_t = LvType("lv_image_t")
lv_gradient_t = LvType("lv_grad_dsc_t")
LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component)
lv_event_t = LvType("lv_event_t")
RotationType = lvgl_ns.enum("RotationType")
lv_point_t = cg.global_ns.struct("lv_point_t")
+65 -34
View File
@@ -36,11 +36,10 @@ from ..defines import (
CONF_SCALE,
CONF_STYLES,
CONF_WIDGETS,
LOGGER,
OBJ_FLAGS,
PARTS,
STATES,
TYPE_FLEX,
TYPE_GRID,
LValidator,
add_lv_use,
call_lambda,
@@ -541,44 +540,76 @@ def _size_to_str(value):
return str(value)
def _grid_descriptor_array(name: str, specs) -> MockObj:
"""Generate a file-scope ``static const`` grid row/column descriptor array
and return a reference to it."""
values = ",".join(_size_to_str(x) for x in specs)
initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}"
arr_id = ID(name, is_declaration=True, type=lv_coord_t)
return cg.static_const_array(arr_id, cg.RawExpression(initializer))
def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None:
"""Apply the layout options present in ``layout`` to ``w``.
Only options actually present are applied, so this works both for widget
creation (where every option is supplied) and for update actions (where the
layout ``type`` and grid structure are fixed and only the style options are
changed). ``base_name`` names the generated grid descriptor arrays and is
only required at creation, when ``grid_rows``/``grid_columns`` are present.
"""
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
w.set_style(CONF_PAD_ROW, pad_row)
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
w.set_style(CONF_PAD_COLUMN, pad_column)
if (rows := layout.get(CONF_GRID_ROWS)) is not None:
w.set_style(
"grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows)
)
if (columns := layout.get(CONF_GRID_COLUMNS)) is not None:
w.set_style(
"grid_column_dsc_array",
_grid_descriptor_array(f"{base_name}_column_dsc", columns),
)
if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None:
w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align))
if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None:
w.set_style(CONF_GRID_ROW_ALIGN, literal(align))
if (flow := layout.get(CONF_FLEX_FLOW)) is not None:
lv_obj.set_flex_flow(w.obj, literal(flow))
if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None:
w.set_style("flex_main_place", literal(main))
if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None:
# Stretch is implemented at creation time by sizing the children; at
# runtime we can only fall back to centering.
if cross == "LV_FLEX_ALIGN_STRETCH":
LOGGER.warning(
"Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead"
)
cross = "LV_FLEX_ALIGN_CENTER"
w.set_style("flex_cross_place", literal(cross))
if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None:
w.set_style("flex_track_place", literal(track))
async def set_obj_properties(w: Widget, config):
"""Generate a list of C++ statements to apply properties to an lv_obj_t"""
from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property
if layout := config.get(CONF_LAYOUT):
layout_type: str = layout[CONF_TYPE]
add_lv_use(layout_type)
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
w.set_style(CONF_PAD_ROW, pad_row)
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
w.set_style(CONF_PAD_COLUMN, pad_column)
if layout_type == TYPE_GRID:
wid = config[CONF_ID]
rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]]
rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}"
row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t)
row_array = cg.static_const_array(row_id, cg.RawExpression(rows))
w.set_style("grid_row_dsc_array", row_array)
columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]]
columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}"
column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t)
column_array = cg.static_const_array(column_id, cg.RawExpression(columns))
w.set_style("grid_column_dsc_array", column_array)
w.set_style(
CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN))
)
w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN)))
if layout_type == TYPE_FLEX:
lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW]))
main = literal(layout[CONF_FLEX_ALIGN_MAIN])
cross = layout[CONF_FLEX_ALIGN_CROSS]
if cross == "LV_FLEX_ALIGN_STRETCH":
cross = "LV_FLEX_ALIGN_CENTER"
cross = literal(cross)
track = literal(layout[CONF_FLEX_ALIGN_TRACK])
lv_obj.set_flex_align(w.obj, main, cross, track)
# The layout `type` (and the grid row/column structure) is only present
# when a widget is created; update actions only change the layout style
# options, leaving the type and grid structure unchanged.
layout_type = layout.get(CONF_TYPE)
if layout_type is not None:
add_lv_use(layout_type)
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
# The widget's own id gives the grid descriptor arrays stable names.
base_name = str(config[CONF_ID])
else:
base_name = None
_set_layout_options(w, layout, base_name)
parts = collect_parts(config)
for part, states in parts.items():
part = "LV_PART_" + part.upper()
+15 -2
View File
@@ -1,3 +1,5 @@
from esphome.components.image import INSTANCE_TYPE as IMAGE_TYPE
from esphome.components.mapping import get_mapping_metadata
import esphome.config_validation as cv
from esphome.const import (
CONF_ANGLE,
@@ -9,7 +11,9 @@ from esphome.const import (
from ..defines import (
CONF_ANTIALIAS,
CONF_IMAGE,
CONF_MAIN,
CONF_MAPPING,
CONF_PIVOT_X,
CONF_PIVOT_Y,
CONF_SCALE,
@@ -21,8 +25,6 @@ from ..types import lv_image_t
from . import Widget, WidgetType
from .label import CONF_LABEL
CONF_IMAGE = "image"
BASE_IMG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PIVOT_X): size,
@@ -69,5 +71,16 @@ class ImgType(WidgetType):
for prop, validator in BASE_IMG_SCHEMA.schema.items():
await w.set_property(prop, config, processor=validator)
def final_validate(self, widget, update_config, widget_config, path):
src = update_config.get(CONF_SRC)
if isinstance(src, dict) and CONF_MAPPING in src:
mapping_id = src[CONF_MAPPING]
metadata = get_mapping_metadata(mapping_id.id)
if str(metadata.to_.data_type) != str(IMAGE_TYPE):
raise cv.Invalid(
f"Mapping '{mapping_id}' does not map to an image type, but '{metadata.to_.data_type}'",
path=path + [CONF_SRC, CONF_MAPPING],
)
img_spec = ImgType()
+28 -6
View File
@@ -56,8 +56,7 @@ void ModbusClientHub::loop() {
(this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address,
this->last_receive_check_ - this->last_send_);
if (wfr.device)
wfr.device->on_modbus_no_response();
this->notify_no_response_(wfr);
this->waiting_for_response_.reset();
}
}
@@ -278,11 +277,10 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct
"ms after last send",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
// Invalidate the waiting device so it won't process this response.
if (wfr.device)
wfr.device->on_modbus_no_response();
// Invalidate the device; the entry survives as an interrupted shell so the late response is ignored.
// A retry requested here stays queued behind the shell until the send-wait timeout clears it.
this->notify_no_response_(wfr);
wfr.interrupted = true;
wfr.device = nullptr;
return;
}
@@ -564,6 +562,30 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo
}
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) {
if (wfr.device == nullptr)
return;
const bool retry = wfr.device->on_modbus_no_response();
// The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach
// over the retry request rather than re-queueing a frame that can no longer be routed.
if (retry && wfr.device != nullptr)
this->requeue_waiting_frame_(wfr);
// The old transaction is over either way; never deliver anything else to the device through it.
wfr.device = nullptr;
}
void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) {
const ModbusFrame &frame = wfr.frame;
if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]);
if (wfr.device != nullptr)
wfr.device->on_modbus_not_sent();
return;
}
// Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell.
this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3);
}
void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
if (pdu_len == 0) {
if (device)
+10 -3
View File
@@ -108,7 +108,7 @@ class ModbusClientHub : public Modbus {
payload, payload_len),
device);
};
void send_pdu(uint8_t address, const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu, ModbusClientDevice *device = nullptr) {
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
this->queue_raw_(address, pdu.data(), pdu.size(), device);
}
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
@@ -121,6 +121,10 @@ class ModbusClientHub : public Modbus {
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void send_next_frame_();
// Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true.
// wfr is the caller's checked reference to waiting_for_response_.
void notify_no_response_(ModbusDeviceCommand &wfr);
void requeue_waiting_frame_(ModbusDeviceCommand &wfr);
void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr);
uint16_t send_wait_time_{2000};
@@ -179,7 +183,10 @@ class ModbusClientDevice {
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
virtual void on_modbus_not_sent() {}
virtual void on_modbus_no_response() {}
/// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry.
/// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and
/// return false when exhausted), or an unresponsive peer will starve other traffic on the bus.
virtual bool on_modbus_no_response() { return false; }
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
const uint8_t *payload = nullptr) {
this->parent_->send_pdu(this->address_,
@@ -187,7 +194,7 @@ class ModbusClientDevice {
payload, payload_len),
this);
}
void send_pdu(const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_pdu(std::span<const uint8_t> pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
@@ -82,7 +82,7 @@ static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0
// 6.3 03 (0x03) Read Holding Registers
// 6.4 04 (0x04) Read Input Registers
static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2)
static constexpr uint16_t MIN_FRAME_SIZE = 4;
+7 -14
View File
@@ -105,8 +105,8 @@ void log_unsupported_value_type(SensorValueType value_type) {
ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast<uint16_t>(value_type));
}
int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask, bool *error_return) {
std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type,
uint8_t offset, uint32_t bitmask) {
int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits
// Validate offset against the buffer for all types, including RAW/unsupported, so
@@ -114,9 +114,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
if (static_cast<size_t>(offset) > size) {
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type),
static_cast<unsigned int>(offset), size);
if (error_return)
*error_return = true;
return value;
return std::nullopt;
}
const size_t required_size = required_payload_size(sensor_value_type);
@@ -127,9 +125,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
if (size - offset < required_size) {
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), size, required_size);
if (error_return)
*error_return = true;
return value;
return std::nullopt;
}
switch (sensor_value_type) {
@@ -179,8 +175,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens
return value;
}
int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type,
bool *error_return) {
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) {
const size_t required_size = required_payload_size(sensor_value_type);
if (required_size == 0) {
return 0; // RAW/unsupported: nothing to read
@@ -189,9 +184,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue
if (required_words > count) {
ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), count, required_words);
if (error_return)
*error_return = true;
return 0;
return std::nullopt;
}
// Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the
// sign-extension behaviour stays identical to the wire path.
@@ -201,7 +194,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue
bytes[i * 2] = static_cast<uint8_t>(reg >> 8);
bytes[i * 2 + 1] = static_cast<uint8_t>(reg & 0xFF);
}
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return);
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
+24 -11
View File
@@ -1,8 +1,10 @@
#pragma once
#include <cmath>
#include <optional>
#include <span>
#include <string>
#include <vector>
#include <cmath>
#include "esphome/core/helpers.h"
#include "esphome/components/modbus/modbus_definitions.h"
@@ -197,11 +199,15 @@ template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_
* @param data modbus response buffer (uint8_t)
* @return content of coil register
*/
inline bool coil_from_vector(int coil, const std::vector<uint8_t> &data) {
auto data_byte = coil / 8;
return (data[data_byte] & (1 << (coil % 8))) > 0;
inline bool bit_from_packed(int bit, std::span<const uint8_t> data) {
auto data_byte = bit / 8;
return (data[data_byte] & (1 << (bit % 8))) > 0;
}
// Remove before 2027.2.0
ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0")
inline bool coil_from_vector(int coil, std::span<const uint8_t> data) { return bit_from_packed(coil, data); }
/** Extract bits from value and shift right according to the bitmask
* if the bitmask is 0x00F0 we want the values frrom bit 5 - 8.
* the result is then shifted right by the position if the first right set bit in the mask
@@ -276,13 +282,21 @@ template<typename Container> void number_to_payload(Container &data, int64_t val
* @param bitmask bitmask used for masking and shifting
* @return 64-bit number of the payload
*/
int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask, bool *error_return = nullptr);
std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type,
uint8_t offset, uint32_t bitmask);
/** Convert vector<uint8_t> response payload to number. */
/** Convert a response payload span to number; std::nullopt if the payload is too short. */
inline std::optional<int64_t> payload_to_number(std::span<const uint8_t> data, SensorValueType sensor_value_type,
uint8_t offset, uint32_t bitmask) {
return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask);
}
// Remove before 2027.2.0
ESPDEPRECATED("Use the std::span overload returning std::optional<int64_t> instead. Removed in 2027.2.0", "2026.8.0")
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask, bool *error_return = nullptr) {
return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return);
uint32_t bitmask) {
// Released behavior: a too-short payload logs an error and decodes to 0.
return payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask).value_or(0);
}
/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload.
@@ -292,8 +306,7 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
* @param sensor_value_type defines if 16/32/64 bits or FP32 is used
* @return 64-bit number of the registers
*/
int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type,
bool *error_return = nullptr);
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
* @param function_code the modbus function code to use. One of:
@@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector<uint8_t> &data) {
case ModbusRegisterType::DISCRETE_INPUT:
case ModbusRegisterType::COIL:
// offset for coil is the actual number of the coil not the byte offset
value = modbus::helpers::coil_from_vector(this->offset, data);
value = modbus::helpers::bit_from_packed(this->offset, data);
break;
default:
value = modbus::helpers::get_data<uint16_t>(data, this->offset) & this->bitmask;
@@ -64,9 +64,10 @@ T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return modbus::helpers::get_data<T>(data, buffer_offset);
}
ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0")
// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed())
ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0")
inline bool coil_from_vector(int coil, const std::vector<uint8_t> &data) {
return modbus::helpers::coil_from_vector(coil, data);
return modbus::helpers::bit_from_packed(coil, data);
}
template<typename N>
@@ -83,7 +84,8 @@ inline void number_to_payload(std::vector<uint16_t> &data, int64_t value, Sensor
ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0")
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask) {
return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask);
return modbus::helpers::payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask)
.value_or(0);
}
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
@@ -377,8 +379,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
* @param item SensorItem object
* @return float value of data
*/
inline float payload_to_float(const std::vector<uint8_t> &data, const SensorItem &item) {
int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask);
inline float payload_to_float(std::span<const uint8_t> data, const SensorItem &item) {
int64_t number =
modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0);
float float_value;
if (modbus::helpers::value_type_is_float(item.sensor_value_type)) {
@@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select";
void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); }
void ModbusSelect::parse_and_publish(const std::vector<uint8_t> &data) {
int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask);
int64_t value = modbus::helpers::payload_to_number(std::span<const uint8_t>(data), this->sensor_value_type,
this->offset, this->bitmask)
.value_or(0);
ESP_LOGD(TAG, "New select value %lld from payload", value);
@@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector<uint8_t> &data) {
case ModbusRegisterType::DISCRETE_INPUT:
case ModbusRegisterType::COIL:
// offset for coil is the actual number of the coil not the byte offset
value = modbus::helpers::coil_from_vector(this->offset, data);
value = modbus::helpers::bit_from_packed(this->offset, data);
break;
default:
value = modbus::helpers::get_data<uint16_t>(data, this->offset) & this->bitmask;
@@ -137,10 +137,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
if (server_register->write_lambda == nullptr) {
return false; // unwritable -> ILLEGAL_DATA_ADDRESS
}
bool error = false;
registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
server_register->value_type, &error);
if (error) {
if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
server_register->value_type)
.has_value()) {
precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value
return false;
}
@@ -154,7 +153,8 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
// rejecting the value at runtime -- which cannot be rolled back.
if (!for_each_register([&registers](ServerRegister *server_register, uint16_t register_offset) {
int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
server_register->value_type);
server_register->value_type)
.value_or(0);
return server_register->write_lambda(number);
})) {
ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied.");
+47 -28
View File
@@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register network connectivity as a provisioning source.
The network component is auto-loaded whenever an interface (wifi, ethernet, ...)
is configured, so a device with connectivity always has this source: it is
considered provisioned once it has connected via any interface, and
`provisioning:` is valid without another source.
"""
from esphome.components import provisioning
provisioning.register_source("network")
return config
def ip_address_literal(ip: str | int | None) -> cg.MockObj:
"""Generate an IPAddress with compile-time initialization instead of runtime parsing.
@@ -128,36 +142,41 @@ def validate_ipv6(value: bool) -> bool:
return value
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
esp32=False,
esp8266=False,
host=False,
rp2=False,
nrf52=True,
): cv.All(
cv.boolean,
cv.Any(
cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
esp32=False,
esp8266=False,
host=False,
rp2=False,
nrf52=True,
): cv.All(
cv.boolean,
cv.Any(
cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
),
cv.boolean_false,
),
cv.boolean_false,
validate_ipv6,
),
validate_ipv6,
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
}
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(
cv.boolean, cv.only_on_esp32
),
}
),
_register_provisioning_source,
)
+3 -1
View File
@@ -250,7 +250,9 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(CONF_VERSION): cv.string_strict,
cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean,
cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
cv.Optional(
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
): cv.Schema(
{
cv.Optional(
CONF_ENABLE_OTA_ROLLBACK, default=True
+21 -136
View File
@@ -1,150 +1,35 @@
import logging
# ---------------------------------------------------------------------------
# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file
# after 2027.1.0.
#
# Online images are now a platform of the `image:` component (`platform:
# online_image`); the real schema, actions and codegen live in `image.py`. This
# module only keeps the deprecated top-level `online_image:` key working during
# the deprecation window: it reuses that schema/codegen and adds a one-shot
# deprecation warning (with a pasteable migrated `image:` block) at validation
# time. Deleting this file drops the top-level form entirely.
# ---------------------------------------------------------------------------
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
import esphome.components.image as espImage
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
from esphome.core import Lambda
from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image
AUTO_LOAD = ["image", "runtime_image"]
DEPENDENCIES = ["display", "http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
MULTI_CONF = True
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_UPDATE = "update"
DOMAIN = "online_image"
_LOGGER = logging.getLogger(__name__)
LEGACY_REMOVAL_VERSION = "2027.1.0"
online_image_ns = cg.esphome_ns.namespace("online_image")
OnlineImage = online_image_ns.class_(
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
_capture_legacy_entry, _warn_legacy_online_image = (
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
)
# Actions
SetUrlAction = online_image_ns.class_(
"OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage)
)
ReleaseImageAction = online_image_ns.class_(
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
)
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA)
FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image
ONLINE_IMAGE_SCHEMA = (
runtime_image.runtime_image_schema(OnlineImage)
.extend(
{
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
}
)
.extend(cv.polling_component_schema("never"))
)
CONFIG_SCHEMA = cv.Schema(
cv.All(
ONLINE_IMAGE_SCHEMA,
cv.require_framework_version(
# esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed
# esp8266_arduino=cv.Version(2, 7, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp_idf=cv.Version(4, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
),
runtime_image.validate_runtime_image_settings,
)
)
SET_URL_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(OnlineImage),
cv.Required(CONF_URL): cv.templatable(cv.url),
cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool),
}
)
RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(OnlineImage),
}
)
@automation.register_action(
"online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True
)
@automation.register_action(
"online_image.release",
ReleaseImageAction,
RELEASE_IMAGE_SCHEMA,
synchronous=True,
)
async def online_image_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if CONF_URL in config:
template_ = await cg.templatable(config[CONF_URL], args, cg.std_string)
cg.add(var.set_url(template_))
if CONF_UPDATE in config:
template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_)
cg.add(var.set_update(template_))
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
async def to_code(config):
# Use the enhanced helper function to get all runtime image parameters
settings = await runtime_image.process_runtime_image_config(config)
add_metadata(
config[CONF_ID],
settings.width,
settings.height,
config[CONF_TYPE],
config[CONF_TRANSPARENCY],
)
url = config[CONF_URL]
var = cg.new_Pvariable(
config[CONF_ID],
url,
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.placeholder or cg.nullptr,
config[CONF_BUFFER_SIZE],
settings.byte_order_big_endian,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
template_ = await cg.templatable(value, [], cg.std_string)
cg.add(var.add_request_header(key, template_))
else:
cg.add(var.add_request_header(key, value))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
to_code = setup_online_image
+152
View File
@@ -0,0 +1,152 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
from esphome.core import Lambda
from esphome.types import ConfigType
AUTO_LOAD = ["runtime_image"]
DEPENDENCIES = ["http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_UPDATE = "update"
online_image_ns = cg.esphome_ns.namespace("online_image")
OnlineImage = online_image_ns.class_(
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
)
# Actions
SetUrlAction = online_image_ns.class_(
"OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage)
)
ReleaseImageAction = online_image_ns.class_(
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
)
ONLINE_IMAGE_SCHEMA = (
runtime_image.runtime_image_schema(OnlineImage)
.extend(
{
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
}
)
.extend(cv.polling_component_schema("never"))
)
# Shared schema used by both the (deprecated) top-level `online_image:` key and
# the `image:` `platform: online_image` entry.
ONLINE_IMAGE_CONFIG_SCHEMA = cv.All(
ONLINE_IMAGE_SCHEMA,
cv.require_framework_version(
# esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed
# esp8266_arduino=cv.Version(2, 7, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp_idf=cv.Version(4, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
),
runtime_image.validate_runtime_image_settings,
)
SET_URL_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(OnlineImage),
cv.Required(CONF_URL): cv.templatable(cv.url),
cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool),
}
)
RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(OnlineImage),
}
)
@automation.register_action(
"online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True
)
@automation.register_action(
"online_image.release",
ReleaseImageAction,
RELEASE_IMAGE_SCHEMA,
synchronous=True,
)
async def online_image_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if CONF_URL in config:
template_ = await cg.templatable(config[CONF_URL], args, cg.std_string)
cg.add(var.set_url(template_))
if CONF_UPDATE in config:
template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_)
cg.add(var.set_update(template_))
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
async def setup_online_image(config: ConfigType) -> None:
# Use the enhanced helper function to get all runtime image parameters
settings = await runtime_image.process_runtime_image_config(config)
add_metadata(
config[CONF_ID],
settings.width,
settings.height,
config[CONF_TYPE],
config[CONF_TRANSPARENCY],
)
url = config[CONF_URL]
var = cg.new_Pvariable(
config[CONF_ID],
url,
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.placeholder or cg.nullptr,
config[CONF_BUFFER_SIZE],
settings.byte_order_big_endian,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
template_ = await cg.templatable(value, [], cg.std_string)
cg.add(var.add_request_header(key, template_))
else:
cg.add(var.add_request_header(key, value))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA
to_code = setup_online_image
+104
View File
@@ -0,0 +1,104 @@
from dataclasses import dataclass, field
import logging
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "provisioning"
_LOGGER = logging.getLogger(__name__)
provisioning_ns = cg.esphome_ns.namespace("provisioning")
ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component)
@dataclass
class ProvisioningData:
# Names of the components that registered as a provisioning source this run.
sources: set[str] = field(default_factory=set)
# Names of source components that have their credentials set in the config.
hardcoded_credentials: set[str] = field(default_factory=set)
def _get_data() -> ProvisioningData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = ProvisioningData()
return CORE.data[DOMAIN]
def register_source(name: str) -> None:
"""Record that ``name`` is a provisioning source for this configuration.
A provisioning-capable component (a transport that boots unprovisioned and is
set up by the controller on first connection, or a network interface that
provisions once connected) calls this while its own config is being processed,
typically from a schema validator. `provisioning:` then confirms at least one
source is present without inspecting the full config or knowing about any
specific component. State lives in CORE.data, which is cleared between runs.
"""
_get_data().sources.add(name)
def report_hardcoded_credentials(name: str) -> None:
"""Record that source component ``name`` has its credentials set in the config.
A source component calls this from its own validator when it finds baked-in
credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:`
warns about these, since a device that ships with credentials does not need a
provisioning window. The warning is emitted here, by `provisioning:`, so the
source components stay unaware of it.
"""
_get_data().hardcoded_credentials.add(name)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ProvisioningManager),
cv.Required(CONF_TIMEOUT): cv.All(
cv.positive_not_null_time_period, cv.positive_time_period_milliseconds
),
cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""Validate the provisioning setup once every component has been processed.
Sources register during their own config validation, so by final validation
both the source set and the hardcoded-credentials set are complete.
"""
data = _get_data()
if not data.sources:
raise cv.Invalid(
"'provisioning' requires at least one provisioning-capable component: "
"configure a network interface such as 'wifi:' or 'ethernet:', or enable "
"'api:' with 'encryption:' and no 'key:' so the device boots "
"unprovisioned and is configured on first connection."
)
if data.hardcoded_credentials:
_LOGGER.warning(
"'provisioning' is configured, but credentials are set in the "
"configuration for: %s. A device that uses a provisioning window should "
"ship without credentials so they are set on first connection; "
"hardcoding them makes the window pointless.",
", ".join(sorted(data.hardcoded_credentials)),
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_PROVISIONING")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
if on_timeout := config.get(CONF_ON_TIMEOUT):
await automation.build_automation(var.get_timeout_trigger(), [], on_timeout)
@@ -0,0 +1,92 @@
#include "esphome/components/provisioning/provisioning.h"
#ifdef USE_PROVISIONING
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#ifdef USE_NETWORK
#include "esphome/components/network/util.h"
#endif
#include <cinttypes>
namespace esphome::provisioning {
static const char *const TAG = "provisioning";
ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
nullptr;
ProvisioningManager::ProvisioningManager() {
global_provisioning_manager = this;
#ifdef USE_NETWORK
// Network connectivity is a built-in provisioning source. Registered here rather
// than from a source's setup() because connectivity is universal, not a pluggable
// transport; loop() latches it provisioned once the device has connected.
this->network_source_ = this->register_source();
#endif
}
uint8_t ProvisioningManager::register_source() {
if (this->source_count_ >= MAX_SOURCES) {
// Defensive: only a handful of sources exist in practice. Fail loudly rather
// than shifting past the mask width (undefined behavior). The returned index is
// ignored by set_source_provisioned()'s bounds check.
ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES);
return this->source_count_;
}
uint8_t source = this->source_count_++;
this->registered_mask_ |= (1UL << source);
return source;
}
void ProvisioningManager::loop() {
// Sources register during their own setup() (at various priorities), and this
// loop() also runs while waiting on a slow component during setup. Evaluating the
// provisioning state before every source has registered could conclude
// "provisioned" prematurely and disable_loop() for good, defeating the window --
// so do nothing until all setup() calls are done.
if (!App.is_setup_complete())
return;
#ifdef USE_NETWORK
// Latch the built-in connectivity source once the device has been reachable via
// any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR
// across interfaces), and a disabled interface never connects so it never
// contributes. Latched: a later link drop does not un-provision -- the RAM-only
// window still reopens only on reboot.
if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected())
this->set_source_provisioned(this->network_source_, true);
#endif
// The window is resolved once the device is provisioned or the window has closed;
// there is nothing left to track, so stop running entirely. Config validation
// guarantees at least one source, so is_provisioned() is never vacuously true here.
if (this->closed_ || this->is_provisioned()) {
this->disable_loop();
return;
}
// The window timer runs from boot (millis since boot). The closed state is not
// persisted, so a reboot reopens the window.
if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) {
this->close_window_();
}
}
void ProvisioningManager::close_window_() {
this->closed_ = true;
ESP_LOGW(TAG, "Window expired; cycle power to reopen window");
// Notify internal consumers first (transports disconnect clients, Improv stops),
// then fire the user-facing automation.
this->closed_callback_.call();
this->timeout_trigger_.trigger();
}
void ProvisioningManager::dump_config() {
ESP_LOGCONFIG(TAG,
"Provisioning:\n"
" Timeout: %" PRIu32 "ms\n"
" Provisioned: %s",
this->timeout_, YESNO(this->is_provisioned()));
}
} // namespace esphome::provisioning
#endif // USE_PROVISIONING
@@ -0,0 +1,96 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PROVISIONING
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
namespace esphome::provisioning {
// Central provisioning-window manager (EN18031). A device that ships unprovisioned
// (secure transports enabled with no credentials, configured by the controller on
// first connection) opens a provisioning window at boot. Each transport that needs
// provisioning registers as a "source" and reports its state; the device is
// considered provisioned once every registered source is provisioned.
//
// Network connectivity is a built-in source: a device with a network interface but
// no other provisioning-capable component (no api encryption, etc.) is still
// considered provisioned once it has connected via any interface -- so an
// Improv-only device reports its state correctly.
//
// If the window times out while still unprovisioned it closes: the closed state is
// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires.
// Components query window_pending()/closed() to suppress reboot timeouts and refuse
// further provisioning. This manager owns no transport knowledge; transports
// (api, and later mqtt/wireguard/...) drive it through the source API.
class ProvisioningManager : public Component {
public:
// Maximum number of provisioning sources, limited by the width of the state masks.
static constexpr uint8_t MAX_SOURCES = 32;
ProvisioningManager();
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
// Register a provisioning source. Returns a bit index the source uses to report
// its state via set_source_provisioned(). Call once, from the source's setup().
uint8_t register_source();
// Report whether the given source currently holds valid credentials.
void set_source_provisioned(uint8_t source, bool provisioned) {
if (source >= MAX_SOURCES)
return;
if (provisioned) {
this->provisioned_mask_ |= (1UL << source);
} else {
this->provisioned_mask_ &= ~(1UL << source);
}
}
// True once every registered source is provisioned. Config validation guarantees
// at least one source, and the built-in connectivity source registers in the
// constructor, so registered_mask_ is never zero in practice.
bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; }
// True while provisioning is still pending: the device is unprovisioned, whether
// the window is still open or has already closed. Reboot timeouts are suppressed
// while this holds so the device never auto-reboots (and silently reopens the
// window) while unprovisioned.
bool window_pending() const { return !this->is_provisioned(); }
// True once the window has expired without the device being provisioned.
bool closed() const { return this->closed_; }
// Register a callback fired once when the window closes (runtime expiry). Used
// internally by transports/Improv to stop accepting provisioning. The user-facing
// on_timeout automation is wired to get_timeout_trigger() instead.
template<typename F> void add_on_closed_callback(F &&callback) {
this->closed_callback_.add(std::forward<F>(callback));
}
Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; }
protected:
void close_window_();
Trigger<> timeout_trigger_;
LazyCallbackManager<void()> closed_callback_;
uint32_t timeout_{0};
uint32_t registered_mask_{0};
uint32_t provisioned_mask_{0};
uint8_t source_count_{0};
bool closed_{false};
#ifdef USE_NETWORK
// Built-in connectivity source (see loop()): registered in the constructor and
// latched provisioned once the device has connected via any network interface.
uint8_t network_source_{0};
#endif
};
extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::provisioning
#endif // USE_PROVISIONING
+6 -2
View File
@@ -244,8 +244,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(
CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY
): cv.string_strict,
cv.Optional(
CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY
): _parse_platform_version,
}
),
_arduino_check_versions,
+1
View File
@@ -138,6 +138,7 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
socket.consume_sockets(1, "sendspin_websocket_client")(config)
wifi.enable_runtime_power_save_control()
wifi.enable_runtime_roaming_suppression()
return config
@@ -129,6 +129,7 @@ void SendspinHub::on_request_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->request_high_performance();
wifi::global_wifi_component->request_roaming_suppression();
}
#endif
}
@@ -137,6 +138,7 @@ void SendspinHub::on_release_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->release_high_performance();
wifi::global_wifi_component->release_roaming_suppression();
}
#endif
}
+64 -25
View File
@@ -60,40 +60,79 @@ def validate_calibration(calibration_config):
return calibration_config
CALIBRATION_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095),
cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095),
cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095),
cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095),
}
),
validate_calibration,
)
def option_with_default(option: str, defaults: dict, required: bool = False):
if option in defaults or not required:
return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED))
return cv.Required(option)
def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False):
calibration = (
cv.Required(CONF_CALIBRATION)
if calibration_required
else cv.Optional(CONF_CALIBRATION)
)
_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX}
_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y}
def _calibration_schema(defaults: dict, required: bool) -> dict:
"""
Generate Calibration schema. If defaults are provided for all suboptions,
the entire calibration config is optional with a populated default value.
Otherwise, it's optional or required as specified.
"""
if _CALIBRATION_KEYS.issubset(defaults):
key = cv.Optional(
CONF_CALIBRATION,
default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS},
)
elif required:
key = cv.Required(CONF_CALIBRATION)
else:
key = cv.Optional(CONF_CALIBRATION)
return {
key: cv.All(
cv.Schema(
{
option_with_default(x, defaults, True): cv.int_range(
min=0, max=4095
)
for x in _CALIBRATION_KEYS
}
),
validate_calibration,
)
}
def _transform_schema(defaults: dict) -> dict:
if _TRANSFORM_KEYS.issubset(defaults):
key = cv.Optional(
CONF_TRANSFORM,
default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS},
)
else:
key = cv.Optional(CONF_TRANSFORM)
return {
key: cv.Schema(
{
cv.Optional(x, default=defaults.get(x, False)): cv.boolean
for x in _TRANSFORM_KEYS
}
)
}
def touchscreen_schema(
default_touch_timeout=cv.UNDEFINED,
calibration_required=False,
defaults: dict = None,
) -> cv.Schema:
defaults = defaults or {}
return cv.Schema(
{
cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display),
cv.Optional(CONF_TRANSFORM): cv.Schema(
{
cv.Optional(CONF_SWAP_XY, default=False): cv.boolean,
cv.Optional(CONF_MIRROR_X, default=False): cv.boolean,
cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean,
}
),
cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
),
calibration: CALIBRATION_SCHEMA,
**_transform_schema(defaults),
**_calibration_schema(defaults, calibration_required),
cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True),
cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True),
+2 -2
View File
@@ -178,7 +178,7 @@ class UARTComponent {
*
* This will load the current UART interface with the latest settings (baud_rate, parity, etc).
*/
virtual void load_settings(bool dump_config){};
virtual void load_settings(bool dump_config) = 0;
/**
* Load the UART settings.
@@ -190,7 +190,7 @@ class UARTComponent {
*
* This will load the current UART interface with the latest settings (baud_rate, parity, etc).
*/
virtual void load_settings(){};
void load_settings() { this->load_settings(true); }
#endif // USE_ESP8266 || USE_ESP32
#ifdef USE_UART_DEBUGGER
@@ -75,7 +75,7 @@ class ESP8266UartComponent final : public UARTComponent, public Component {
* This will load the current UART interface with the latest settings (baud_rate, parity, etc).
*/
void load_settings(bool dump_config) override;
void load_settings() override { this->load_settings(true); }
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
protected:
void check_logger_conflict() override;
@@ -50,7 +50,7 @@ class IDFUARTComponent final : public UARTComponent, public Component {
* This will load the current UART interface with the latest settings (baud_rate, parity, etc).
*/
void load_settings(bool dump_config) override;
void load_settings() override { this->load_settings(true); }
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
protected:
void check_logger_conflict() override;
@@ -84,6 +84,12 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented<USBC
bool read_array(uint8_t *data, size_t len) override;
size_t available() override;
uart::UARTFlushResult flush() override;
#if defined(USE_ESP8266) || defined(USE_ESP32)
// No-op: in CDC ACM device mode the host dictates the line coding, so there are no
// local UART settings to (re)apply.
void load_settings(bool dump_config) override {}
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
#endif
protected:
void check_logger_conflict() override;
+89 -95
View File
@@ -50,47 +50,44 @@ static const CH34xEntry CH34X_TABLE[] = {
{"CH346C_M2", 0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, 2},
};
void USBUartTypeCH34X::enable_channels() {
usb_host::transfer_cb_t cb = [this](const usb_host::TransferStatus &status) {
if (!status.success) {
this->defer([this, error_code = status.error_code]() {
ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code));
this->apply_line_settings_();
});
return;
}
CH34xChipType chiptype = CHIP_UNKNOWN;
uint8_t num_ports = 1;
for (const auto &e : CH34X_TABLE) {
if (e.pid != this->pid_)
continue;
if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match)
continue;
chiptype = e.chiptype;
num_ports = e.num_ports;
bool USBUartTypeCH34X::config_device_step(uint8_t step, bool ok, const uint8_t *response) {
if (step == 0) {
// Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes
// used to distinguish CH34x variants sharing the same PID.
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0});
return true;
}
// step 1: parse the chip-version response (falling back to "unknown" on failure).
if (!ok) {
ESP_LOGE(TAG, "CH34x chip detection failed");
return false;
}
CH34xChipType chiptype = CHIP_UNKNOWN;
uint8_t num_ports = 1;
for (const auto &e : CH34X_TABLE) {
if (e.pid != this->pid_)
continue;
if (e.match != 0xFF && (response[e.byte_idx] & e.mask) != e.match)
continue;
chiptype = e.chiptype;
num_ports = e.num_ports;
break;
}
// CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1])
if (chiptype == CHIP_CH344L && (response[0] & 0xF0) != 0x40)
chiptype = CHIP_CH344L_V2;
const char *name = "unknown";
for (const auto &e : CH34X_TABLE) {
if (e.chiptype == chiptype) {
name = e.name;
break;
}
// CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1])
if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40)
chiptype = CHIP_CH344L_V2;
const char *name = "unknown";
for (const auto &e : CH34X_TABLE) {
if (e.chiptype == chiptype) {
name = e.name;
break;
}
}
this->defer([this, chiptype, num_ports, name]() {
this->chiptype_ = chiptype;
this->chip_name_ = name;
this->num_ports_ = num_ports;
ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_);
this->apply_line_settings_();
});
};
// Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes
// used to distinguish CH34x variants sharing the same PID.
this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0});
}
this->chiptype_ = chiptype;
this->chip_name_ = name;
this->num_ports_ = num_ports;
ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_);
return false;
}
void USBUartTypeCH34X::dump_config() {
@@ -98,67 +95,64 @@ void USBUartTypeCH34X::dump_config() {
ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_);
}
void USBUartTypeCH34X::apply_line_settings_() {
for (auto *channel : this->channels_) {
if (!channel->initialised_.load())
continue;
usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
uint8_t cmd = 0xA1 + channel->index_;
if (channel->index_ >= 2)
cmd += 0xE;
switch (step) {
case 0: {
uint8_t divisor = 7;
uint32_t clk = 12000000;
auto baud_rate = channel->baud_rate_;
if (baud_rate < 256000) {
if (baud_rate > 6000000 / 255) {
divisor = 3;
clk = 6000000;
} else if (baud_rate > 750000 / 255) {
divisor = 2;
clk = 750000;
} else if (baud_rate > 93750 / 255) {
divisor = 1;
clk = 93750;
} else {
divisor = 0;
clk = 11719;
}
}
};
uint8_t divisor = 7;
uint32_t clk = 12000000;
auto baud_rate = channel->baud_rate_;
if (baud_rate < 256000) {
if (baud_rate > 6000000 / 255) {
divisor = 3;
clk = 6000000;
} else if (baud_rate > 750000 / 255) {
divisor = 2;
clk = 750000;
} else if (baud_rate > 93750 / 255) {
divisor = 1;
clk = 93750;
} else {
divisor = 0;
clk = 11719;
ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk);
auto factor = static_cast<uint8_t>(clk / baud_rate);
if (factor == 0 || factor == 0xFF) {
ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate);
return false;
}
}
ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk);
auto factor = static_cast<uint8_t>(clk / baud_rate);
if (factor == 0 || factor == 0xFF) {
ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate);
channel->initialised_.store(false);
continue;
}
if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1)))
factor++;
factor = 256 - factor;
if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1)))
factor++;
factor = 256 - factor;
uint16_t value = 0xC0;
if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2)
value |= 4;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
break;
default:
value |= 8 | ((channel->parity_ - 1) << 4);
break;
uint16_t value = 0xC0;
if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2)
value |= 4;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
break;
default:
value |= 8 | ((channel->parity_ - 1) << 4);
break;
}
value |= channel->data_bits_ - 5;
value <<= 8;
value |= 0x8C;
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor);
return true;
}
value |= channel->data_bits_ - 5;
value <<= 8;
value |= 0x8C;
uint8_t cmd = 0xA1 + channel->index_;
if (channel->index_ >= 2)
cmd += 0xE;
this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback);
this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback);
case 1:
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0);
return true;
default:
return false;
}
this->start_channels_();
}
std::vector<CdcEps> USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) {
+24 -22
View File
@@ -97,29 +97,31 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
return cdc_devs;
}
void USBUartTypeCP210X::enable_channels() {
// enable the channels
for (auto *channel : this->channels_) {
if (!channel->initialised_.load())
continue;
usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
}
};
this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback);
uint16_t line_control = channel->stop_bits_;
line_control |= static_cast<uint8_t>(channel->parity_) << 4;
line_control |= channel->data_bits_ << 8;
ESP_LOGD(TAG, "Line control value 0x%X", line_control);
this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_,
callback);
auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE);
this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback,
baud.get_data());
bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
// On reload, skip the one-time IFC_ENABLE step (the interface is already enabled).
if (reload)
step++;
switch (step) {
case 0:
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_);
return true;
case 1: {
uint16_t line_control = channel->stop_bits_;
line_control |= static_cast<uint8_t>(channel->parity_) << 4;
line_control |= channel->data_bits_ << 8;
ESP_LOGD(TAG, "Line control value 0x%X", line_control);
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_);
return true;
}
case 2: {
auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE);
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data());
return true;
}
default:
return false;
}
this->start_channels_();
}
} // namespace esphome::usb_uart
+81 -155
View File
@@ -112,40 +112,46 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t
return best_baud;
}
static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value,
uint16_t *index) {
struct FtdiConfig {
uint16_t value;
uint16_t ftdi_index;
int best_baud;
};
static FtdiConfig ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index) {
uint32_t encoded_divisor;
FtdiConfig config{};
if (baudrate <= 0) {
return -1;
return config;
}
static constexpr uint32_t H_CLK = 120000000;
static constexpr uint32_t C_CLK = 48000000;
if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) {
if (baudrate * 10 > H_CLK / 0x3fff) {
best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor);
config.best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor);
encoded_divisor |= 0x20000; /* switch on CLK/10*/
} else {
best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor);
config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor);
}
} else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) {
best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor);
config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor);
} else {
best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor);
config.best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor);
}
*value = (uint16_t) (encoded_divisor & 0xFFFF);
config.value = (uint16_t) (encoded_divisor & 0xFFFF);
if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) {
*index = (uint16_t) (encoded_divisor >> 8);
*index &= 0xFF00;
*index |= (channel_index + 1);
config.ftdi_index = (uint16_t) (encoded_divisor >> 8);
config.ftdi_index &= 0xFF00;
config.ftdi_index |= (channel_index + 1);
} else {
*index = (uint16_t) (encoded_divisor >> 16);
config.ftdi_index = (uint16_t) (encoded_divisor >> 16);
}
return best_baud;
return config;
}
static optional<CdcEps> get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) {
@@ -264,138 +270,6 @@ std::vector<CdcEps> USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev
return cdc_devs;
}
int USBUartTypeFT23XX::reset_(USBUartChannel *channel) {
usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
} else {
ESP_LOGD(TAG, "Reset successful, setting baudrate...");
this->set_baudrate_(channel);
}
};
bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00,
channel->cdc_dev_.bulk_interface_number + 1, callback);
if (!ok) {
ESP_LOGE(TAG, "Reset control_transfer submit failed");
channel->initialised_.store(false);
return -1;
}
return 0;
}
int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) {
usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
} else {
ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_);
this->set_line_properties_(channel);
}
};
if (baudrate == 0) {
baudrate = channel->baud_rate_;
}
uint16_t value = 0, ftdi_index = 0;
ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index);
ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index);
uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1);
bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback);
if (!ok) {
ESP_LOGE(TAG, "Set baudrate control_transfer submit failed");
channel->initialised_.store(false);
return -1;
}
return 0;
}
int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) {
usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
return;
}
ESP_LOGD(TAG, "Line properties set, setting modem control...");
this->set_dtr_rts_(channel);
};
uint16_t value = channel->data_bits_;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
value |= (0x00 << 8);
break;
case UART_CONFIG_PARITY_ODD:
value |= (0x01 << 8);
break;
case UART_CONFIG_PARITY_EVEN:
value |= (0x02 << 8);
break;
case UART_CONFIG_PARITY_MARK:
value |= (0x03 << 8);
break;
case UART_CONFIG_PARITY_SPACE:
value |= (0x04 << 8);
break;
}
switch (channel->stop_bits_) {
case UART_CONFIG_STOP_BITS_1:
value |= (0x00 << 11);
break;
case UART_CONFIG_STOP_BITS_1_5:
value |= (0x01 << 11);
break;
case UART_CONFIG_STOP_BITS_2:
value |= (0x02 << 11);
break;
}
value |= (0x00 << 14);
bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value,
channel->cdc_dev_.bulk_interface_number + 1, callback);
if (!ok) {
ESP_LOGE(TAG, "Set line properties control_transfer submit failed");
channel->initialised_.store(false);
return -1;
}
return 0;
}
int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) {
usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code));
channel->initialised_.store(false);
return;
}
ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_);
channel->initialised_.store(true);
this->start_input(channel);
uint8_t next_index = channel->index_ + 1;
if (next_index < this->channels_.size()) {
USBUartChannel *next_channel = this->channels_[next_index];
ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_);
this->reset_(next_channel);
return;
} else {
ESP_LOGI(TAG, "All channels configured");
}
};
bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000,
channel->cdc_dev_.bulk_interface_number + 1, callback);
if (!ok) {
ESP_LOGE(TAG, "Set modem control control_transfer submit failed");
channel->initialised_.store(false);
return -1;
}
return 0;
}
void USBUartTypeFT23XX::start_input(USBUartChannel *channel) {
if (!channel->initialised_.load())
return;
@@ -467,16 +341,68 @@ void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) {
channel->input_buffer_.clear();
}
void USBUartTypeFT23XX::enable_channels() {
if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) {
this->reset_(this->channels_[0]);
}
for (auto *channel : this->channels_) {
if (!channel->initialised_.load())
continue;
channel->input_started_.store(false);
channel->output_started_.store(false);
bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
// On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios
// path only re-applies baud + line properties and does not re-assert DTR/RTS.
if (reload)
step++;
switch (step) {
case 0: // SIO reset (init only)
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00,
channel->cdc_dev_.bulk_interface_number + 1);
return true;
case 1: { // set baudrate
auto config = ftdi_convert_baudrate(channel->baud_rate_, this->chip_type_, channel->index_);
uint16_t usb_index = (config.ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1);
ESP_LOGD(TAG, "Baudrate: %u, value=0x%04X, ftdi_index=0x%04X", (unsigned) channel->baud_rate_, config.value,
config.ftdi_index);
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, config.value, usb_index);
return true;
}
case 2: { // set line properties (data bits / parity / stop bits)
uint16_t value = channel->data_bits_;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
value |= (0x00 << 8);
break;
case UART_CONFIG_PARITY_ODD:
value |= (0x01 << 8);
break;
case UART_CONFIG_PARITY_EVEN:
value |= (0x02 << 8);
break;
case UART_CONFIG_PARITY_MARK:
value |= (0x03 << 8);
break;
case UART_CONFIG_PARITY_SPACE:
value |= (0x04 << 8);
break;
}
switch (channel->stop_bits_) {
default: // 1 bit
value |= (0x00 << 11);
break;
case UART_CONFIG_STOP_BITS_1_5:
value |= (0x01 << 11);
break;
case UART_CONFIG_STOP_BITS_2:
value |= (0x02 << 11);
break;
}
value |= (0x00 << 14);
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value,
channel->cdc_dev_.bulk_interface_number + 1);
return true;
}
case 3: // set modem control DTR+RTS (init only)
if (reload)
return false;
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000,
channel->cdc_dev_.bulk_interface_number + 1);
return true;
default:
return false;
}
}
+99 -85
View File
@@ -200,100 +200,114 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
return cdc_devs;
}
void USBUartTypePL2303::enable_channels() {
if (this->channels_.empty())
return;
// Vendor init sequence for non-HXN chips (mirrors pl2303_startup in the Linux driver):
// read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, write 0x0404=1,
// read 0x8484, read 0x8383, write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+).
// The final entry's wIndex is patched at runtime depending on the chip type.
struct Pl2303InitStep {
uint8_t type;
uint8_t request;
uint16_t value;
uint16_t index;
bool read; // reads need a 1-byte buffer to set wLength=1 so the IN data stage runs
};
static const Pl2303InitStep PL2303_INIT[] = {
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true},
{VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 0, false},
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true},
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true},
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true},
{VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 1, false},
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true},
{VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true},
{VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0, 1, false},
{VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 1, 0, false},
{VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 2, 0, false},
};
static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]);
auto *channel = this->channels_[0];
bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
bool is_legacy = (this->chip_type_ == PL2303_TYPE_H);
bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN);
usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) {
if (!status.success)
ESP_LOGW(TAG, "PL2303: vendor init transfer failed");
};
// Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver):
// Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484,
// write 0x0404=1, read 0x8484, read 0x8383,
// write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+)
if (!is_hxn) {
uint8_t req = VENDOR_READ_REQUEST;
uint8_t wreq = VENDOR_WRITE_REQUEST;
// Fire-and-forget vendor reads: result discarded, chip requires this sequence.
// Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed.
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0});
this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb);
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0});
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0});
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0});
this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb);
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0});
this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0});
this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb);
this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb);
this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb);
// Vendor init burst runs only on full init for non-HXN chips.
uint8_t init_count = (!reload && !is_hxn) ? PL2303_INIT_COUNT : 0;
if (step < init_count) {
const auto &e = PL2303_INIT[step];
uint16_t index = (step == PL2303_INIT_COUNT - 1) ? (is_legacy ? 0x24 : 0x44) : e.index;
this->config_transfer_(e.type, e.request, e.value, index,
e.read ? std::vector<uint8_t>{0} : std::vector<uint8_t>{});
return true;
}
step -= init_count;
// Build 7-byte line coding structure:
// [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits
uint8_t line_coding[7] = {};
uint32_t baud = channel->get_baud_rate();
// Choose baud encoding based on chip type
uint32_t nearest = nearest_supported_baud(baud);
if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) {
encode_baud_direct(line_coding, baud);
} else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) {
encode_baud_divisor_alt(line_coding, baud);
} else {
encode_baud_divisor(line_coding, baud);
}
// Stop bits: 0=1, 1=1.5, 2=2
switch (channel->get_stop_bits()) {
case 2:
line_coding[4] = 2;
break;
default:
line_coding[4] = 0;
break;
}
// Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space
switch (channel->parity_) {
case UART_CONFIG_PARITY_ODD:
line_coding[5] = 1;
break;
case UART_CONFIG_PARITY_EVEN:
line_coding[5] = 2;
break;
case UART_CONFIG_PARITY_MARK:
line_coding[5] = 3;
break;
case UART_CONFIG_PARITY_SPACE:
line_coding[5] = 4;
break;
default:
line_coding[5] = 0;
break;
}
// Data bits
line_coding[6] = channel->get_data_bits();
ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4],
line_coding[5], line_coding[6]);
std::vector<uint8_t> lc_vec(line_coding, line_coding + 7);
uint16_t iface = channel->cdc_dev_.bulk_interface_number;
this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec);
switch (step) {
case 0: {
// Build 7-byte line coding structure:
// [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits
uint8_t line_coding[7] = {};
uint32_t baud = channel->get_baud_rate();
// Assert DTR + RTS
this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb);
// Choose baud encoding based on chip type
uint32_t nearest = nearest_supported_baud(baud);
if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) {
encode_baud_direct(line_coding, baud);
} else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) {
encode_baud_divisor_alt(line_coding, baud);
} else {
encode_baud_divisor(line_coding, baud);
}
this->start_channels_();
// Stop bits: 0=1, 1=1.5, 2=2
switch (channel->get_stop_bits()) {
case 2:
line_coding[4] = 2;
break;
default:
line_coding[4] = 0;
break;
}
// Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space
switch (channel->parity_) {
case UART_CONFIG_PARITY_ODD:
line_coding[5] = 1;
break;
case UART_CONFIG_PARITY_EVEN:
line_coding[5] = 2;
break;
case UART_CONFIG_PARITY_MARK:
line_coding[5] = 3;
break;
case UART_CONFIG_PARITY_SPACE:
line_coding[5] = 4;
break;
default:
line_coding[5] = 0;
break;
}
// Data bits
line_coding[6] = channel->get_data_bits();
ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5],
line_coding[6]);
std::vector<uint8_t> lc_vec(line_coding, line_coding + 7);
this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec);
return true;
}
case 1:
// Assert DTR + RTS (init only)
if (reload)
return false;
this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface);
return true;
default:
return false;
}
}
} // namespace esphome::usb_uart
+166 -42
View File
@@ -6,6 +6,7 @@
#include "esphome/core/application.h"
#include <cinttypes>
#include <cstring>
namespace esphome::usb_uart {
@@ -213,6 +214,7 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) {
void USBUartComponent::setup() { USBClient::setup(); }
void USBUartComponent::loop() {
bool had_work = this->process_usb_events_();
had_work |= this->run_config_machine_();
// Process USB data from the lock-free queue
UsbDataChunk *chunk;
@@ -489,60 +491,182 @@ void USBUartTypeCdcAcm::on_disconnected() {
USBClient::on_disconnected();
}
void USBUartTypeCdcAcm::enable_channels() {
bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE;
static constexpr uint8_t CDC_SET_LINE_CODING = 0x20;
static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22;
static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS
for (auto *channel : this->channels_) {
if (!channel->initialised_.load())
continue;
// Configure the bridge's UART parameters. A USB-UART bridge will not forward data
// at the correct speed until SET_LINE_CODING is sent; without it the UART may run
// at an indeterminate default rate so the NCP receives garbled bytes and never
// sends RSTACK.
uint32_t baud = channel->baud_rate_;
std::vector<uint8_t> line_coding = {
static_cast<uint8_t>(baud & 0xFF), static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF), static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop
static_cast<uint8_t>(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space
static_cast<uint8_t>(channel->data_bits_), // bDataBits
};
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_,
(unsigned) channel->parity_, channel->data_bits_);
this->control_transfer(
CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number,
[](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGW(TAG, "SET_LINE_CODING failed: %X", status.error_code);
} else {
ESP_LOGD(TAG, "SET_LINE_CODING OK");
}
},
line_coding);
// Assert DTR+RTS to signal DTE is present.
this->control_transfer(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS,
channel->cdc_dev_.interrupt_interface_number, [](const usb_host::TransferStatus &status) {
if (!status.success) {
ESP_LOGW(TAG, "SET_CONTROL_LINE_STATE failed: %X", status.error_code);
} else {
ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE (DTR+RTS) OK");
}
});
switch (step) {
case 0: {
// Configure the bridge's UART parameters. A USB-UART bridge will not forward data
// at the correct speed until SET_LINE_CODING is sent; without it the UART may run
// at an indeterminate default rate so the NCP receives garbled bytes and never
// sends RSTACK.
uint32_t baud = channel->baud_rate_;
std::vector<uint8_t> line_coding = {
static_cast<uint8_t>(baud & 0xFF), static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF), static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop
static_cast<uint8_t>(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space
static_cast<uint8_t>(channel->data_bits_), // bDataBits
};
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_,
(unsigned) channel->parity_, channel->data_bits_);
this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number,
line_coding);
return true;
}
case 1:
// Assert DTR+RTS to signal DTE is present (init only).
if (reload)
return false;
this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS,
channel->cdc_dev_.interrupt_interface_number);
return true;
default:
return false;
}
this->start_channels_();
}
void USBUartTypeCdcAcm::start_channels_() {
for (auto *channel : this->channels_) {
if (!channel->initialised_.load())
continue;
void USBUartComponent::enable_channels() {
this->cfg_single_ = nullptr;
this->cfg_pending_reload_ = nullptr;
this->cfg_channel_idx_ = 0;
this->start_config_(false);
}
void USBUartComponent::apply_channel_settings(USBUartChannel *channel) {
if (this->cfg_active_) {
// A config sequence is already running. Defer this reload until it finishes to preserve
// the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an
// in-flight callback complete against fresh state). The pending slot coalesces multiple
// requests; the channel's live settings are read when the reload eventually runs.
// Note: multiple channel reloads are not queued; only one pending reload is supported at a time.
this->cfg_pending_reload_ = channel;
return;
}
this->cfg_single_ = channel;
this->start_config_(true);
}
void USBUartComponent::start_config_(bool reload) {
this->cfg_reload_ = reload;
this->cfg_device_phase_ = !reload;
this->cfg_step_ = 0;
this->cfg_ok_ = true;
this->cfg_in_flight_ = false;
this->cfg_done_.store(false);
this->cfg_active_ = true;
this->enable_loop();
}
void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index,
const std::vector<uint8_t> &data) {
this->cfg_done_.store(false);
// The completion callback runs in the USB-task context: it only records the result and
// wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread.
bool submitted = this->control_transfer(
type, request, value, index,
[this](const usb_host::TransferStatus &status) {
this->cfg_ok_ = status.success;
if (!status.success) {
ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code));
} else if (status.data_len > 0) {
memcpy(this->cfg_response_, status.data, std::min<size_t>(status.data_len, sizeof(this->cfg_response_)));
}
// Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_.
this->cfg_done_.store(true, std::memory_order_release);
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
},
data);
if (!submitted) {
// Submission failed (e.g. no free transfer request). No callback will fire, so synthesize
// a failed completion here so the state machine advances/aborts instead of hanging.
ESP_LOGW(TAG, "Config control transfer submit failed");
this->cfg_ok_ = false;
this->cfg_done_.store(true, std::memory_order_release);
}
}
bool USBUartComponent::run_config_machine_() {
if (!this->cfg_active_)
return false;
if (this->cfg_in_flight_) {
// Acquire: pairs with the release in config_transfer_'s callback.
if (!this->cfg_done_.load(std::memory_order_acquire))
return false; // still waiting; the callback will re-wake the loop (no busy spin)
this->cfg_in_flight_ = false;
this->cfg_done_.store(false);
this->cfg_step_++;
}
// cfg_ok_ is now synchronized (we only get here on the initial entry or after observing
// cfg_done_ with acquire ordering), so it is safe to read.
ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_,
this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_);
// One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself.
if (this->cfg_device_phase_) {
if (this->config_device_step(this->cfg_step_, this->cfg_ok_, this->cfg_response_)) {
this->cfg_in_flight_ = true;
return true;
}
this->cfg_device_phase_ = false;
this->cfg_step_ = 0;
this->cfg_ok_ = true;
}
USBUartChannel *channel =
this->cfg_single_ != nullptr
? this->cfg_single_
: (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr);
if (channel != nullptr && channel->initialised_.load()) {
if (!this->cfg_ok_) {
// A previous step in this channel's sequence failed. Abort the rest. On a full init,
// mark the channel uninitialised so data flow isn't started on a misconfigured channel;
// on a reload, leave the already-working channel as it was.
if (!this->cfg_reload_)
channel->initialised_.store(false);
} else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) {
this->cfg_in_flight_ = true;
return true;
}
}
// Channel finished (or aborted). On full init, kick off data flow if still initialised.
if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) {
channel->input_started_.store(false);
channel->output_started_.store(false);
this->start_input(channel);
}
// Advance to the next channel (or finish).
this->cfg_step_ = 0;
this->cfg_ok_ = true;
if (this->cfg_single_ != nullptr) {
this->cfg_active_ = false;
this->cfg_single_ = nullptr;
} else if (++this->cfg_channel_idx_ >= this->channels_.size()) {
this->cfg_active_ = false;
}
// If the machine just went idle and a reload was requested while it was busy, start it now.
if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) {
this->cfg_single_ = this->cfg_pending_reload_;
this->cfg_pending_reload_ = nullptr;
this->start_config_(true);
}
return true;
}
void USBUartChannel::load_settings(bool /*dump_config*/) {
// The per-channel control transfers already log their values at debug level.
this->parent_->apply_channel_settings(this);
}
} // namespace esphome::usb_uart
+50 -16
View File
@@ -146,7 +146,9 @@ class USBUartChannel final : public uart::UARTComponent, public Parented<USBUart
size_t available() override { return this->input_buffer_.get_available(); }
bool is_connected() override { return this->initialised_.load(); }
uart::UARTFlushResult flush() override;
void check_logger_conflict() override {}
// Re-apply the current line settings (baud, parity, etc) to this already-open channel.
void load_settings(bool dump_config) override;
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
void set_parity(UARTParityOptions parity) { this->parity_ = parity; }
void set_debug(bool debug) { this->debug_ = debug; }
void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; }
@@ -160,6 +162,7 @@ class USBUartChannel final : public uart::UARTComponent, public Parented<USBUart
void set_rx_callback(std::function<void()> cb) { this->rx_callback_ = std::move(cb); }
protected:
void check_logger_conflict() override {}
// Larger structures first (8+ bytes)
RingBuffer input_buffer_;
LockFreeQueue<UsbOutputChunk, USB_OUTPUT_CHUNK_COUNT> output_queue_;
@@ -195,6 +198,12 @@ class USBUartComponent : public usb_host::USBClient {
virtual void start_input(USBUartChannel *channel);
void start_output(USBUartChannel *channel);
// Begin configuring all channels (full initialisation). Called from on_connected().
void enable_channels();
// Re-apply line settings to a single, already-open channel (used by
// USBUartChannel::load_settings()).
void apply_channel_settings(USBUartChannel *channel);
// Called from loop() when input_buffer_ has insufficient space for the incoming chunk.
// Default is a no-op; override in device-specific subclasses that need resync on overflow.
virtual void on_rx_overflow(USBUartChannel *channel) {}
@@ -206,7 +215,41 @@ class USBUartComponent : public usb_host::USBClient {
EventPool<UsbDataChunk, USB_DATA_QUEUE_SIZE - 1> chunk_pool_;
protected:
// Issue one control transfer as part of the setup state machine. The completion
// callback (USB-task context) records the result/IN data, marks the step done and
// wakes the loop so run_config_machine_() advances on the loop thread. Call exactly
// once from config_step_()/config_device_step_() when issuing a step.
void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index,
const std::vector<uint8_t> &data = {});
// (Re)start the config state machine. reload=false runs full init over all channels;
// reload=true re-applies settings to cfg_single_ only.
void start_config_(bool reload);
// Advance the config state machine; called from loop(). Returns true if it did work.
bool run_config_machine_();
// Per-subclass per-channel settings sequence. For the given zero-based step, issue the
// next control transfer via config_transfer_() and return true, or return false when the
// channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip
// enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data.
virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0;
// Optional one-time device-level setup run before the per-channel phase on init only
// (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps.
virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; }
std::vector<USBUartChannel *> channels_{};
// Config state machine
USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel
USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy
std::atomic<bool> cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads
uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads)
uint8_t cfg_channel_idx_{0};
uint8_t cfg_step_{0};
bool cfg_active_{false};
bool cfg_reload_{false};
bool cfg_device_phase_{false};
bool cfg_in_flight_{false};
bool cfg_ok_{true};
};
class USBUartTypeCdcAcm : public USBUartComponent {
@@ -217,11 +260,7 @@ class USBUartTypeCdcAcm : public USBUartComponent {
virtual std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl);
void on_connected() override;
void on_disconnected() override;
virtual void enable_channels();
/// Resets per-channel transfer flags and posts the first bulk IN transfer.
/// Called by enable_channels() and by vendor-specific subclass overrides that
/// handle their own line-coding setup before starting data flow.
void start_channels_();
bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
};
class USBUartTypeCP210X : public USBUartTypeCdcAcm {
@@ -230,7 +269,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm {
protected:
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
void enable_channels() override;
bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
};
class USBUartTypeCH34X : public USBUartTypeCdcAcm {
public:
@@ -238,11 +277,11 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm {
void dump_config() override;
protected:
void enable_channels() override;
bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
private:
void apply_line_settings_();
CH34xChipType chiptype_{CHIP_UNKNOWN};
const char *chip_name_{"unknown"};
uint8_t num_ports_{1};
@@ -257,12 +296,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm {
protected:
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
void enable_channels() override;
int reset_(USBUartChannel *channel);
int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0);
int set_line_properties_(USBUartChannel *channel);
int set_dtr_rts_(USBUartChannel *channel);
bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
uint8_t chip_type_{255};
};
@@ -285,7 +319,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm {
protected:
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
void enable_channels() override;
bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN};
};
@@ -32,15 +32,27 @@
namespace esphome::web_server_idf {
// Status strings not provided by esp_http_server.h
#ifndef HTTPD_401
#define HTTPD_401 "401 Unauthorized"
#endif
#ifndef HTTPD_409
#define HTTPD_409 "409 Conflict"
#endif
#ifndef HTTPD_422
#define HTTPD_422 "422 Unprocessable Entity"
#endif
#define CRLF_STR "\r\n"
#define CRLF_LEN (sizeof(CRLF_STR) - 1)
static const char *const TAG = "web_server_idf";
// Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size.
// Buffers of this size must live on the heap - the httpd task stack is too small.
static constexpr size_t RECV_CHUNK_SIZE = 1460;
static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog
// Global instance to avoid guard variable (saves 8 bytes)
// This is initialized at program startup before any threads
namespace {
@@ -184,9 +196,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) {
return server->handle_multipart_upload_(r, content_type_char);
#endif
} else {
ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char);
// fallback to get handler to support backward compatibility
return AsyncWebServer::request_handler(r);
// Other content types (e.g. application/json) are delivered raw to a matching
// custom handler via handleBody(), like the Arduino AsyncWebServer does
auto *server = static_cast<AsyncWebServer *>(r->user_ctx);
return server->handle_raw_body_(r, content_type_char);
}
}
@@ -237,6 +250,51 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const
return ESP_ERR_NOT_FOUND;
}
esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_type) {
AsyncWebServerRequest req(r);
AsyncWebHandler *handler = nullptr;
for (auto *h : this->handlers_) {
if (h->canHandle(&req)) {
handler = h;
break;
}
}
if (handler == nullptr) {
ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type);
// fallback to get handler to support backward compatibility
return this->request_handler_(&req);
}
const size_t total = r->content_len;
if (total > 0) {
auto buffer = std::make_unique_for_overwrite<char[]>(RECV_CHUNK_SIZE);
size_t bytes_since_yield = 0;
for (size_t index = 0; index < total;) {
int recv_len = httpd_req_recv(r, buffer.get(), std::min(total - index, RECV_CHUNK_SIZE));
if (recv_len <= 0) {
httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST,
nullptr);
return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL;
}
handler->handleBody(&req, reinterpret_cast<uint8_t *>(buffer.get()), recv_len, index, total);
index += recv_len;
bytes_since_yield += recv_len;
if (bytes_since_yield > YIELD_INTERVAL_BYTES) {
vTaskDelay(1);
bytes_since_yield = 0;
}
}
}
handler->handleRequest(&req);
return ESP_OK;
}
AsyncWebServerRequest::~AsyncWebServerRequest() {
delete this->rsp_;
for (auto *param : this->params_) {
@@ -276,12 +334,24 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
case 200:
status = HTTPD_200;
break;
case 204:
status = HTTPD_204;
break;
case 400:
status = HTTPD_400;
break;
case 401:
status = HTTPD_401;
break;
case 404:
status = HTTPD_404;
break;
case 409:
status = HTTPD_409;
break;
case 422:
status = HTTPD_422;
break;
default:
status = HTTPD_500;
break;
@@ -893,9 +963,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e
#ifdef USE_WEBSERVER_OTA
esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) {
static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size
static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog
// Parse boundary and create reader
const char *boundary_start;
size_t boundary_len;
@@ -949,12 +1016,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c
}
});
// Use heap buffer - 1460 bytes is too large for the httpd task stack
auto buffer = std::make_unique_for_overwrite<char[]>(MULTIPART_CHUNK_SIZE);
auto buffer = std::make_unique_for_overwrite<char[]>(RECV_CHUNK_SIZE);
size_t bytes_since_yield = 0;
for (size_t remaining = r->content_len; remaining > 0;) {
int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE));
int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, RECV_CHUNK_SIZE));
if (recv_len <= 0) {
httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST,
@@ -233,6 +233,7 @@ class AsyncWebServer {
static esp_err_t request_post_handler(httpd_req_t *r);
esp_err_t request_handler_(AsyncWebServerRequest *request) const;
static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd);
esp_err_t handle_raw_body_(httpd_req_t *r, const char *content_type);
#ifdef USE_WEBSERVER_OTA
esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type);
#endif
+9
View File
@@ -381,6 +381,15 @@ class WeikaiChannel : public uart::UARTComponent {
/// we wait until all bytes are gone with a timeout of 100 ms
uart::UARTFlushResult flush() override;
#if defined(USE_ESP8266) || defined(USE_ESP32)
/// @brief Re-apply the current line settings (baud, parity, etc) to the channel.
void load_settings(bool dump_config) override {
this->set_line_param_();
this->set_baudrate_();
}
using UARTComponent::load_settings; // also bring in the no-arg overload for convenience
#endif
protected:
friend class WeikaiComponent;
+16
View File
@@ -436,6 +436,21 @@ def _validate(config):
return config
def _report_provisioning_credentials(config):
"""Report baked-in STA credentials to the provisioning component (if used).
`_validate` has already folded any ``ssid``/``password`` into ``networks``, so a
non-empty list means credentials are set in the config. `provisioning:` warns
about this, since a device that uses a provisioning window should get its
credentials on first connection instead.
"""
if config.get(CONF_NETWORKS):
from esphome.components import provisioning
provisioning.report_hardcoded_credentials("wifi")
return config
CONF_PASSIVE_SCAN = "passive_scan"
FAST_CONNECT_SCHEMA = cv.Schema(
@@ -517,6 +532,7 @@ CONFIG_SCHEMA = cv.All(
),
_apply_min_auth_mode_default,
_validate,
_report_provisioning_credentials,
)
+18 -2
View File
@@ -45,6 +45,10 @@
#include "esphome/components/improv_serial/improv_serial_component.h"
#endif
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
namespace esphome::wifi {
static const char *const TAG = "wifi";
@@ -872,8 +876,20 @@ void WiFiComponent::loop() {
if (!this->has_ap() && this->reboot_timeout_ != 0) {
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
bool suppress = false;
#ifdef USE_PROVISIONING
// Don't reboot while a provisioning window is pending (device unprovisioned).
// The device is legitimately waiting to be onboarded (Wi-Fi must come up
// before the controller can set credentials), and an auto-reboot would reopen
// the window without the deliberate power cycle / reset that is meant to be
// required. Resumes normal reboot behavior once provisioned.
suppress = provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
#endif
if (!suppress) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
}
}
}
}
+17 -4
View File
@@ -18,10 +18,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
from .const import (
CONF_ENDPOINT,
CONF_MAX_EP_NUMBER,
CONF_ON_JOIN,
CONF_POWER_SOURCE,
CONF_REPORT,
CONF_ROUTER,
CONF_USE_DEVICE_TYPE,
CONF_WIPE_ON_BOOT,
KEY_ZIGBEE,
POWER_SOURCE,
@@ -31,7 +34,7 @@ from .const import (
)
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
CONF_MAX_EP_NUMBER,
CONF_MAX_EP_NUMBER_ZEPHYR,
CONF_SLEEPY,
CONF_ZIGBEE_ID,
KEY_EP_NUMBER,
@@ -71,7 +74,17 @@ BASE_SCHEMA = cv.Schema(
cv.requires_component("esp32"),
_check_report_deprecation,
cv.enum(REPORT, lower=True),
)
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.int_range(1, CONF_MAX_EP_NUMBER),
),
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.boolean,
),
}
)
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor)
@@ -148,8 +161,8 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType:
_LOGGER.warning(
"Single endpoint requires ZHA or at leatst Zigbee2MQTT 2.8.0. For older versions of Zigbee2MQTT use multiple endpoints"
)
if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode:
raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}")
if count > CONF_MAX_EP_NUMBER_ZEPHYR and not CORE.testing_mode:
raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER_ZEPHYR}")
return config
+3
View File
@@ -58,11 +58,14 @@ REPORT = {
"default": report.ZIGBEE_REPORT_DEFAULT,
}
CONF_ENDPOINT = "endpoint"
CONF_MAX_EP_NUMBER = 239
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
CONF_USE_DEVICE_TYPE = "use_device_type"
POWER_SOURCE = {
"UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN
"MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE
+2 -5
View File
@@ -2,16 +2,13 @@ import esphome.codegen as cg
DEVICE_TYPE = "device_type"
ROLE = "role"
CONF_MAX_EP_NUMBER = 239
CONF_NUM = "num"
CONF_CLUSTERS = "clusters"
CONF_ATTRIBUTES = "attributes"
CONF_ENDPOINT = "endpoint"
CONF_CLUSTER = "cluster"
SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_BS_EP = "binary_sensor_ep"
KEY_SENSOR_EP = "sensor_ep"
KEY_ZIGBEE_EP = "zigbee_ep"
KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num"
DEVICE_ID = {
"RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"),
+1 -1
View File
@@ -1,4 +1,4 @@
CONF_MAX_EP_NUMBER = 8
CONF_MAX_EP_NUMBER_ZEPHYR = 8
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
+133 -24
View File
@@ -2,16 +2,22 @@ from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE
from esphome.core import CORE
from .const import CONF_REPORT, REPORT
from .const import (
CONF_MAX_EP_NUMBER,
CONF_REPORT,
CONF_USE_DEVICE_TYPE,
KEY_ZIGBEE,
REPORT,
)
from .const_esp32 import (
CLUSTER_ROLE,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_MAX_EP_NUMBER,
CONF_NUM,
DEVICE_TYPE,
KEY_ZIGBEE_EP,
KEY_ZIGBEE_EP_NO_NUM,
ROLE,
)
@@ -22,12 +28,12 @@ ep_configs: dict[str, dict[str, Any]] = {
CONF_CLUSTERS: [
{
CONF_ID: "BINARY_INPUT",
ROLE: CLUSTER_ROLE["SERVER"],
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "BOOL",
CONF_REPORT: REPORT["default"],
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONF_DEVICE: None,
},
{
@@ -47,16 +53,15 @@ ep_configs: dict[str, dict[str, Any]] = {
],
},
"analog_input": {
DEVICE_TYPE: "CUSTOM_ATTR",
CONF_CLUSTERS: [
{
CONF_ID: "ANALOG_INPUT",
ROLE: CLUSTER_ROLE["SERVER"],
ROLE: "SERVER",
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "SINGLE",
CONF_REPORT: REPORT["default"],
CONF_REPORT: cv.enum(REPORT, lower=True)("default"),
CONF_DEVICE: None,
},
{
@@ -78,22 +83,126 @@ ep_configs: dict[str, dict[str, Any]] = {
}
def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]:
def get_next_ep_num(eps: list[int]) -> int:
try:
ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0]
eps.append(ep_num)
except IndexError as e:
raise cv.Invalid(
f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints."
) from e
return ep_num
def merge_endpoint(
existing_ep: dict[str, Any],
ep_num: int | None,
ep: dict[str, Any],
use_type: bool | None,
skip_error: bool,
) -> bool:
add = True
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]:
if cl in existing_clusters:
if not skip_error:
raise cv.Invalid(
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
)
add = False
break
if not add:
return False
if (
use_type
and existing_ep.get(CONF_USE_DEVICE_TYPE)
and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE)
):
if not skip_error:
raise cv.Invalid(
f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both."
)
return False
if use_type:
existing_ep[CONF_USE_DEVICE_TYPE] = use_type
if ep.get(DEVICE_TYPE):
existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE]
else:
existing_ep.pop(DEVICE_TYPE, None)
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True
if existing_ep.get(CONF_USE_DEVICE_TYPE):
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True
if (
ep.get(DEVICE_TYPE)
and existing_ep.get(DEVICE_TYPE)
and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE]
):
if not skip_error:
raise cv.Invalid(
f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both."
)
return False
if ep.get(DEVICE_TYPE):
existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE]
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True
def create_ep(router: bool) -> None:
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, [])
# create dummy endpoint if list is empty
if not ep_list:
if not ep_dict and not ep_list:
ep_type = "CUSTOM_ATTR"
if router:
ep_type = "RANGE_EXTENDER"
ep_list = [
{
DEVICE_TYPE: ep_type,
}
]
# enumerate endpoints
for i, ep in enumerate(ep_list, 1):
ep[CONF_NUM] = i
if len(ep_list) > CONF_MAX_EP_NUMBER:
raise cv.Invalid(
f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints."
)
return ep_list
ep_dict[1] = {DEVICE_TYPE: ep_type}
if ep_list:
# merge endpoint with different clusters
ep_list_new: list[dict] = []
for ep in ep_list:
added = False
for existing_ep in ep_list_new:
if merge_endpoint(
existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True
):
added = True
break
if not added:
ep_list_new.append(ep)
# Add endpoints with no number to the endpoint dict with a new number
eps = list(ep_dict.keys())
for ep in ep_list_new:
ep_num = get_next_ep_num(eps)
ep_dict[ep_num] = ep
# clear list so that it is not processed again
del zb_data[KEY_ZIGBEE_EP_NO_NUM]
# Add default device type to endpoints that have none
for ep in ep_dict.values():
if not ep.get(DEVICE_TYPE):
ep[DEVICE_TYPE] = "CUSTOM_ATTR"
def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None:
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
if ep_num is None:
if use_type:
ep[CONF_USE_DEVICE_TYPE] = use_type
ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, [])
ep_list.append(ep)
else:
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
if ep_num in ep_dict:
# check if the existing endpoint has same clusters
existing_ep = ep_dict[ep_num]
merge_endpoint(existing_ep, ep_num, ep, use_type, False)
else:
if use_type is not None:
ep[CONF_USE_DEVICE_TYPE] = use_type
ep_dict[ep_num] = ep
+16 -20
View File
@@ -35,9 +35,11 @@ from .const import (
ANALOG_INPUT_APPTYPE,
BACNET_UNIT_NO_UNITS,
BACNET_UNITS,
CONF_ENDPOINT,
CONF_POWER_SOURCE,
CONF_REPORT,
CONF_ROUTER,
CONF_USE_DEVICE_TYPE,
KEY_ZIGBEE,
POWER_SOURCE,
ZigbeeAttribute,
@@ -45,18 +47,17 @@ from .const import (
from .const_esp32 import (
ATTR_TYPE,
CLUSTER_ID,
CLUSTER_ROLE,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_NUM,
DEVICE_ID,
DEVICE_TYPE,
KEY_BS_EP,
KEY_SENSOR_EP,
KEY_ZIGBEE_EP,
ROLE,
SCALE,
)
from .zigbee_ep_esp32 import create_ep, ep_configs
from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs
_LOGGER = logging.getLogger(__name__)
@@ -146,6 +147,7 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
raise cv.Invalid(
f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'"
)
create_ep(config.get(CONF_ROUTER))
return config
@@ -199,18 +201,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType:
},
)
setup_attributes(config, ep[CONF_CLUSTERS])
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, [])
sensor_ep.append(ep)
add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE))
return config
def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType:
ep = copy.deepcopy(ep_configs["binary_input"])
setup_attributes(config, ep[CONF_CLUSTERS])
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, [])
binary_sensor_ep.append(ep)
add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE))
return config
@@ -243,7 +241,7 @@ async def attributes_to_code(
var.add_attr(
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
CLUSTER_ROLE[cl[ROLE]],
attr[CONF_ATTRIBUTE_ID],
attr.get(CONF_MAX_LENGTH, 0),
attr[CONF_VALUE],
@@ -255,7 +253,7 @@ async def attributes_to_code(
var,
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
CLUSTER_ROLE[cl[ROLE]],
attr[CONF_ATTRIBUTE_ID],
ATTR_TYPE[attr[CONF_TYPE]],
attr.get(SCALE, 1),
@@ -287,9 +285,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
# create endpoints
zb_data = CORE.data.get(KEY_ZIGBEE, {})
sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, [])
binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, [])
ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER))
ep_dict: dict[int, dict] = zb_data.get(KEY_ZIGBEE_EP, {})
# setup zigbee components
var = cg.new_Pvariable(config[CONF_ID])
@@ -301,15 +297,15 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
POWER_SOURCE[config[CONF_POWER_SOURCE]],
)
)
for ep in ep_list:
cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]]))
for ep_num, ep in ep_dict.items():
cg.add(var.create_default_cluster(ep_num, DEVICE_ID[ep[DEVICE_TYPE]]))
for cl in ep.get(CONF_CLUSTERS, []):
cg.add(
var.add_cluster(
ep[CONF_NUM],
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
CLUSTER_ROLE[cl[ROLE]],
)
)
await attributes_to_code(var, ep[CONF_NUM], cl)
await attributes_to_code(var, ep_num, cl)
return var
+12
View File
@@ -599,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep):
CORE.loaded_integrations.add(self.domain)
# For platform components, normalize conf before creating MetadataValidationStep
if component.is_platform_component:
# Legacy config migration: allow a platform component to rewrite a
# pre-platform-format top-level config (e.g. a bare list or legacy
# dict form) into the normalized list of `platform:` tagged entries.
# Removable deprecation shim hook; no-op for components that do not
# define LEGACY_CONFIG_MIGRATE.
if (
(migrate := component.legacy_config_migrate) is not None
and self.conf
and not isinstance(self.conf, core.AutoLoad)
and (migrated := migrate(self.conf)) is not None
):
result[self.domain] = self.conf = migrated
if not self.conf:
result[self.domain] = self.conf = []
elif not isinstance(self.conf, list):
+34 -12
View File
@@ -284,14 +284,24 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_COMMENT): cv.All(
cv.string, cv.ByteLength(max=COMMENT_MAX_LEN)
),
cv.Required(CONF_BUILD_PATH): cv.string,
cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema(
cv.Required(CONF_BUILD_PATH, visibility=cv.Visibility.YAML_ONLY): cv.string,
cv.Optional(
CONF_PLATFORMIO_OPTIONS,
default={},
visibility=cv.Visibility.YAML_ONLY,
): cv.Schema(
{
cv.string_strict: cv.Any([cv.string], cv.string),
}
),
cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema(
cv.Optional(
CONF_BUILD_FLAGS, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(cv.string_strict),
cv.Optional(
CONF_ENVIRONMENT_VARIABLES,
default={},
visibility=cv.Visibility.YAML_ONLY,
): cv.Schema(
{
cv.string_strict: cv.string,
}
@@ -313,12 +323,20 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoopTrigger),
}
),
cv.Optional(CONF_INCLUDES, default=[]): cv.ensure_list(valid_include),
cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include),
cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict),
cv.Optional(
CONF_INCLUDES, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(valid_include),
cv.Optional(
CONF_INCLUDES_C, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(valid_include),
cv.Optional(
CONF_LIBRARIES, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean,
cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean,
cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean,
cv.Optional(
CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY
): cv.boolean,
cv.Optional(CONF_PROJECT): cv.Schema(
{
cv.Required(CONF_NAME): cv.All(
@@ -338,11 +356,15 @@ CONFIG_SCHEMA = cv.All(
),
}
),
cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All(
cv.version_number, cv.validate_esphome_version
),
cv.Optional(
CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default
CONF_MIN_VERSION,
default=ESPHOME_VERSION,
visibility=cv.Visibility.ADVANCED,
): cv.All(cv.version_number, cv.validate_esphome_version),
cv.Optional(
CONF_COMPILE_PROCESS_LIMIT,
default=_compile_process_limit_default,
visibility=cv.Visibility.ADVANCED,
): cv.int_range(min=1, max=get_usable_cpu_count()),
cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA),
cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA),
+2
View File
@@ -89,6 +89,7 @@
#define USE_LOGGER_LEVEL_LISTENERS
#define USE_LOGGER_RUNTIME_TAG_LEVELS
#define USE_LVGL
#define USE_LVGL_ANIMATION
#define USE_LVGL_ANIMIMG
#define USE_LVGL_ARC
#define USE_LVGL_BINARY_SENSOR
@@ -153,6 +154,7 @@
#define USE_OUTPUT_FLOAT_POWER_SCALING
#define USE_POWER_SUPPLY
#define USE_PREFERENCES_SYNC_EVERY_LOOP
#define USE_PROVISIONING
#define USE_QR_CODE
#define USE_SAFE_MODE_CALLBACK
#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1
+13
View File
@@ -135,6 +135,19 @@ class ComponentManifest:
"""
return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None)
@property
def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None:
"""Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module.
Called once, before platform entries are processed, with the raw top-level
config for this domain. It may transform a pre-platform-format config (e.g.
a bare list or legacy dict form) into the normalized list of `platform:`
tagged entries and return it. Returning ``None`` means "already in the new
format, leave untouched". This is an intentionally removable deprecation
shim hook.
"""
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
@property
def resources(self) -> list[FileResource]:
"""Return a list of all file resources defined in the package of this component.
-11
View File
@@ -390,16 +390,6 @@ def fix_mapping():
output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config
def fix_image():
if "image" not in output:
return
from esphome.components.image import IMAGE_SCHEMA
config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA")
config["is_list"] = True
output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config
def fix_menu():
if "display_menu_base" not in output:
return
@@ -763,7 +753,6 @@ def build_schema():
fix_font()
fix_globals()
fix_mapping()
fix_image()
add_logger_tags()
shrink()
fix_menu()
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,30 @@
# New `image:` `platform: animation` form. Exercises animation/image.py through
# the real platform loader and codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
image:
- platform: animation
id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
- platform: animation
id: test_animation_no_loop
file: anim.gif
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,25 @@
# Legacy top-level `animation:` form. Exercises the deprecation shim and the
# shared codegen path through the real read_config/codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
animation:
- id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,81 @@
"""Tests for the animation image platform and the legacy `animation:` shim."""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.components.animation import (
DOMAIN,
LEGACY_REMOVAL_VERSION,
_capture_legacy_entry,
_warn_legacy_animation,
)
from esphome.core import CORE
from esphome.types import ConfigType
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after
# 2027.1.0 together with the shim in esphome/components/animation/__init__.py.
# ---------------------------------------------------------------------------
def test_warn_legacy_animation_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"}
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
assert _capture_legacy_entry(config) is config
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_animation(config) is config
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_animation(config) is config
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "deprecated" in caplog.text
assert "platform: animation" in caplog.text
assert LEGACY_REMOVAL_VERSION in caplog.text
def test_legacy_animation_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The legacy `animation:` block validates, warns, and generates codegen
through the real read_config/codegen pipeline."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(component_config_path("animation_test.yaml"))
# Deprecation warning surfaced through the real validation pipeline.
assert "animation" in caplog.text
assert "deprecated" in caplog.text
# setup_animation ran: Animation object constructed and loop configured.
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
def test_animation_platform_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The `image:` `platform: animation` form generates codegen through the
real platform loader (animation/image.py) without any deprecation warning."""
main_cpp = generate_main(component_config_path("animation_platform_test.yaml"))
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
# The loop-less entry constructs the object but never configures a loop.
assert "new(test_animation_no_loop) animation::Animation(" in main_cpp
assert "test_animation_no_loop->set_loop(" not in main_cpp
+34
View File
@@ -601,6 +601,40 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
def test_esp32_build_internals_are_yaml_only() -> None:
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
The framework block's PlatformIO package pins, raw ESP-IDF
sdkconfig options, the low-level ``advanced`` block, extra IDF
component sources, plus the partition table and toolchain override
on the main schema are build internals never UI form fields.
User-facing choices (framework type/version, board, variant, )
stay on the main form.
"""
from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA
fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema}
for field in (
"release",
"source",
"platform_version",
"sdkconfig_options",
"advanced",
"components",
):
assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field
# Framework type/version remain user-facing.
assert fw_markers["type"].visibility is None
assert fw_markers["version"].visibility is None
main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema}
assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY
# toolchain is a real but rarely-touched override -> advanced disclosure.
assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED
assert main_markers["board"].visibility is None
assert main_markers["flash_size"].visibility is None
def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None:
assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == []

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