mirror of
https://github.com/esphome/esphome.git
synced 2026-07-09 00:15:36 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ccf0dbd37 | |||
| 7c130fc970 | |||
| 640e0973ac | |||
| 9088875491 | |||
| 99ec2cc00a | |||
| 99ff7e198a | |||
| d4bb20d34b | |||
| 2db001710c | |||
| dd0d0942f5 | |||
| 26f48ee9ea | |||
| 84f4fbeaa8 | |||
| ce46895270 | |||
| e7933a5387 | |||
| b787281388 | |||
| bba3a9657b | |||
| 2f5465c0e8 | |||
| 9c40ed5d71 | |||
| 93bc02b308 | |||
| b8af90750f | |||
| 65ef05dd1f | |||
| 731486d9b0 | |||
| 731e9fda03 | |||
| 35c7496cd7 | |||
| 71b746b6fa | |||
| 1913818b1c |
@@ -1,3 +1,5 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(){};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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_);
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([®isters](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.");
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user