diff --git a/.agents/skills/component-codegen/SKILL.md b/.agents/skills/component-codegen/SKILL.md index f7d99d43736..5d6364b3560 100644 --- a/.agents/skills/component-codegen/SKILL.md +++ b/.agents/skills/component-codegen/SKILL.md @@ -188,9 +188,37 @@ Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `res drive a state when the restore mode is disabled. A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which already applies `inverted`. -**Entities that each watch a register field:** one class taking register, shift, mask and match from a -Python tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a -virtual listener base (a second vptr per entity). +**A hub that forwards data to its children:** when what each child does with the data is known at build time +(decode a register field, match a key or tag, publish a value), declare a `cg.HubFunction` once in the hub's +`__init__.py` and have each platform add one item per entity, built from codegen expressions. Code generation writes +one function per hub. Pass `define=` and put the hub's `HUB_PUBLISHER(, args...)` (in a public section; it +ends on `public:`) and its call under that `#ifdef`; call the pointer only when it is not null. The children stay +plain entities, with no listener class or list. A child that lost a `Component` base keeps its dump lines through +a second hub function (`"dump"`, a `const char *` tag parameter, no define of its own) that the hub's +`dump_config()` calls inside the value function's `#ifdef`; every child adds to both. + +**Entities that each watch a register field:** when every field (register, shift, mask, match) is known at +build time, use the rule above (see `bq25186`). A binary sensor tests its field value against an on-values +bitmap (`match`); a one-bit field reads the bit (simplified from bq25186's `_state_expression`): +```python +# hub __init__.py +STATUS_PUBLISHER = cg.HubFunction( + "status", # esphome____status_fn, passed to HUB_PUBLISHER(status, ...)'s setter + cg.void, + [(BQ25186StatusRegisters.const_ref(), "status")], + define="USE_BQ25186_STATUS_PUBLISHER", +) +STATUS = STATUS_PUBLISHER.param("status") + +# platform to_code, per configured entity +value = (STATUS[reg] >> shift) & mask +if mask != 0x01 or match != 0b10: # 0b10: a one-bit field that is on when set + value = (cg.MockObj(f"{match:#06b}") >> value) & 1 +STATUS_PUBLISHER.add(hub, parent_ref(var).publish_state(value)) +``` +Store a field description per entity (a struct in a `StaticVector` sized by `cg.slot_counter`) only when the +field is chosen at run time, as in `sen6x`, where the status bit depends on the variant read in `setup()`. +Never a template per bit or a virtual listener base (a second vptr per entity). ## Other helpers diff --git a/.agents/skills/embedded-memory/SKILL.md b/.agents/skills/embedded-memory/SKILL.md index ab66687841b..8e16ded4423 100644 --- a/.agents/skills/embedded-memory/SKILL.md +++ b/.agents/skills/embedded-memory/SKILL.md @@ -58,8 +58,11 @@ APIs. Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration. **Listener and child-entity registration lists are the most common case, and the most commonly - missed.** A `register_*()` method called once per child at code generation time has a count that is - known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it returns a + missed.** When the hub only forwards data to its children and each child's handling is known at build + time, keep no list at all: use `cg.HubFunction` (see the component-codegen skill). When the hub must + store the children (it iterates them for other reasons, or picks fields at run time), a `register_*()` + method called once per child at code generation time has a count that is known at compile time, so it + should never be a `std::vector`. Use `cg.slot_counter()`: it returns a function that each consumer calls once per slot it will occupy, and after every `to_code` has run it emits the define with the final count. When nothing registers, no define is emitted and the storage plus its registration method compile out entirely. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8527cf491b0..930aa622030 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2026.06.1 +ARG BUILD_BASE_VERSION=2026.11.0 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/CODEOWNERS b/CODEOWNERS index 9bb0f23c290..ea48bb99e28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -633,6 +633,7 @@ esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 esphome/components/statsd/* @Links2004 esphome/components/stcc4/* @j9brown +esphome/components/store_yaml/* @bdraco esphome/components/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 11656ff0b72..0ffd791bae2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -94,23 +94,24 @@ without a password leaves OTA open. The API is documented and is meant to be called by other devices, scripts, and pages. As defense-in-depth, the web server checks the `Origin` header on browser requests -to its entity control and state endpoints: a request whose `Origin` does not match -the address the device is served on is rejected, and the `allowed_origins` option +to every endpoint served through it, including the web OTA `/update` endpoint +(the captive portal pages are open by design): a request whose `Origin` does not match +the `Host` header of the same request is rejected, and the `allowed_origins` option widens that list. This blocks the common "confused deputy" (CSRF) case where a page the operator visits drives the device through their browser. It is **not** an authentication boundary: it only constrains browsers. Any client that omits the `Origin` header — `curl`, scripts, or other non-browser callers on the same -network — reaches every endpoint exactly as before. The check also does not cover -the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +network — reaches every endpoint exactly as before. The device performs no CSRF-token or `Referer` validation. The following are therefore **not** vulnerabilities in this repository: - Requests without an `Origin` header (for example `curl`) reaching the control endpoints, whether or not `web_server` `auth:` is set. - Requests from an origin the operator added to `allowed_origins`. -- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when - web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not - covered by the `Origin` check; this is the same exposure as running OTA without a - password. +- DNS rebinding: a page whose host name resolves to the device sends a matching + `Origin` and `Host`, so the check lets it through. +- Firmware upload through the web OTA endpoint (`/update`) by a client that sends no + `Origin` header when web OTA is enabled without `web_server` `auth:`; this is the + same exposure as running OTA without a password. The supported defenses are `web_server` `auth:`, protecting OTA (a web password or a native OTA password), and keeping devices on a trusted, segmented network. See diff --git a/docker/Dockerfile b/docker/Dockerfile index a6211cd000b..a01c6d70204 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.1 +ARG BUILD_BASE_VERSION=2026.11.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker diff --git a/esphome/automation.py b/esphome/automation.py index e1cea9281d8..0d8954cc09d 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -45,6 +45,9 @@ CONF_CHOOSE = "choose" CONF_CHOICE_ID = "choice_id" +_BYTE_VECTOR = cg.std_vector.template(cg.uint8) + + def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" if not data: @@ -60,10 +63,11 @@ async def templatable_bytes( set_template: MockObj, set_static: MockObj, table_name: str, + return_type: MockObj = _BYTE_VECTOR, ) -> None: """Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static.""" if cg.is_template(value): - fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + fn = await cg.templatable(value, args, return_type) cg.add(set_template(fn)) else: cg.add(set_static(*_static_bytes(table_name, value))) @@ -884,7 +888,7 @@ async def _render_bytes( return f"{receiver}{target.format(data, parent=parent)};" if isinstance(value, Lambda): - call = await _call_user_lambda(value, args, cg.std_vector.template(cg.uint8)) + call = await _call_user_lambda(value, args, _BYTE_VECTOR) return [ "{", f" const std::vector esphome__bytes = {call};", diff --git a/esphome/codegen.py b/esphome/codegen.py index 5e32fdd38cd..27790f7a2ee 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -54,6 +54,7 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + HubFunction, add_optional, add_pin, add_variable, diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index af11d925faa..f4daa0154ca 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -22,6 +22,7 @@ service APIConnection { rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {} rpc wizard_input_set (WizardInputSetRequest) returns (void) {} + rpc crash_report (CrashReportRequest) returns (CrashReportResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -83,6 +84,7 @@ service APIConnection { rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} + rpc get_yaml(GetYamlRequest) returns (void) {} } @@ -401,6 +403,11 @@ message ZWaveProxyCapabilities { uint32 home_id = 2; } +message StoreYamlCapabilities { + // Firmware embeds its YAML, so clients can request it with get_yaml + bool supported = 1; +} + message WizardCapabilities { // True when the device has a wizard configured, so DeviceWizardRequest will be answered bool configured = 1; @@ -414,6 +421,12 @@ message SendspinCapabilities { string client_id = 2 [(max_data_length) = 43]; } +message CrashReportCapabilities { + // Bitmask of CrashReportFlag. ESP32 checks BUILD_MATCHES against the build time stored with the record; + // ESP8266, RP2 and LibreTiny store none and assume it. + uint32 flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -425,6 +438,8 @@ message DeviceCapabilitiesResponse { [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; + StoreYamlCapabilities store_yaml = 7 [(field_ifdef) = "USE_STORE_YAML"]; + CrashReportCapabilities crash_report = 8 [(field_ifdef) = "USE_CRASH_REPORT"]; } // ==================== DEVICE WIZARD ==================== @@ -538,6 +553,36 @@ message SubscribeStatesRequest { // Empty } +// ==================== CRASH REPORT ==================== + +// Send only when crash_report.flags has PRESENT. The report is dropped once sent. +message CrashReportRequest { + option (id) = 163; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_CRASH_REPORT"; + // Empty +} + +message CrashReportCore { + uint32 core = 1; + repeated uint32 backtrace = 2 [(fixed_array_with_length_define) = "CRASH_REPORT_MAX_BACKTRACE"]; +} + +message CrashReportResponse { + option (id) = 164; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_CRASH_REPORT"; + + uint32 flags = 1; // Bitmask of CrashReportFlag + uint32 reason = 2; // CrashReason + uint32 pc = 3; + uint32 cause = 4; + uint32 fault_address = 5; + uint32 build_time = 6; + uint32 crashed_core = 7; + repeated CrashReportCore cores = 8 [(fixed_array_with_length_define) = "CRASH_REPORT_MAX_CORES"]; +} + // ==================== COMMON ===================== enum EntityCategory { @@ -2192,6 +2237,8 @@ message BluetoothScannerSetModeRequest { enum VoiceAssistantSubscribeFlag { VOICE_ASSISTANT_SUBSCRIBE_NONE = 0; VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1; + // Not 2 or 4: aioesphomeapi sends API audio as 1 << 2 + VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO = 8; } message SubscribeVoiceAssistantRequest { @@ -2225,6 +2272,11 @@ message VoiceAssistantRequest { uint32 flags = 3; VoiceAssistantAudioSettings audio_settings = 4; string wake_word_phrase = 5; + string wake_word_id = 6; + uint32 wake_word_audio_bytes = 7; + float wake_word_probability = 8; + float wake_word_probability_cutoff = 9; + uint32 wake_word_audio_bytes2 = 10; } message VoiceAssistantResponse { @@ -2250,6 +2302,7 @@ enum VoiceAssistantEvent { VOICE_ASSISTANT_WAKE_WORD_END = 10; VOICE_ASSISTANT_STT_VAD_START = 11; VOICE_ASSISTANT_STT_VAD_END = 12; + VOICE_ASSISTANT_WAKE_WORD_REJECTED = 13; VOICE_ASSISTANT_TTS_STREAM_START = 98; VOICE_ASSISTANT_TTS_STREAM_END = 99; VOICE_ASSISTANT_INTENT_PROGRESS = 100; @@ -2843,7 +2896,7 @@ message InfraredRFReceiveEvent { uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance - repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods + repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::span"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } // Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the @@ -3071,6 +3124,7 @@ message UsbDeviceDescriptor { uint32 interface_number = 4; // bInterfaceNumber the port is bound through uint32 interface_class = 5; // bInterfaceClass of that interface uint32 port_number = 6; + string interface_description = 7; // iInterface string of that interface, empty when it has none } // The identity of the device behind a port. Identifies one physical device among others of @@ -3143,3 +3197,25 @@ message SendspinPairingTokenResponse { // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. string token = 2 [(max_data_length) = 127]; } + +// ==================== STORE YAML ==================== +// Streams the compressed user YAML embedded in firmware; the client decompresses. +// A GetYamlRequest during an active transfer is ignored; reconnect to restart. +message GetYamlRequest { + option (id) = 161; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_STORE_YAML"; + option (no_delay) = true; +} + +message GetYamlResponse { + option (id) = 162; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_STORE_YAML"; + + // A zstd frame of the EHY1 file envelope, sent from flash + bytes data = 1 [(force) = true, (pointer_to_buffer) = true, (progmem) = true]; + bool done = 2 [(force) = true]; + // Set on the first chunk only + uint32 total_size = 3; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c687ac3e4ef..fbcc1cfb814 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -366,6 +366,13 @@ void APIConnection::loop() { // (missing a frame is fine, missing a state update is not) this->try_send_camera_image_(); #endif + +#ifdef USE_STORE_YAML + // Guard inline so the idle hot path pays a compare, not a function call. + if (this->store_yaml_pos_ != STORE_YAML_IDLE) { + this->try_send_store_yaml_(); + } +#endif } void APIConnection::check_keepalive_(uint32_t now) { @@ -1222,6 +1229,36 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { } #endif +#ifdef USE_STORE_YAML +void APIConnection::on_get_yaml_request() { + // A re-request while a transfer is in flight is ignored (see api.proto) + if (this->store_yaml_pos_ != STORE_YAML_IDLE) + return; + this->store_yaml_pos_ = 0; + this->try_send_store_yaml_(); +} + +void APIConnection::try_send_store_yaml_() { + // Advance only after a successful send, so a full TX buffer retries the same chunk from loop() + while (this->helper_->can_write_without_blocking()) { + const size_t to_send = std::min(STORE_YAML_DATA_SIZE - this->store_yaml_pos_, MAX_BATCH_PACKET_SIZE); + GetYamlResponse resp; + resp.data = STORE_YAML_DATA + this->store_yaml_pos_; + resp.data_len = to_send; + if (this->store_yaml_pos_ == 0) + resp.total_size = STORE_YAML_DATA_SIZE; + resp.done = this->store_yaml_pos_ + to_send == STORE_YAML_DATA_SIZE; + if (!this->send_message(resp)) + return; + if (resp.done) { + this->store_yaml_pos_ = STORE_YAML_IDLE; + return; + } + this->store_yaml_pos_ += to_send; + } +} +#endif + #ifdef USE_HOMEASSISTANT_TIME void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { @@ -1316,7 +1353,7 @@ bool APIConnection::check_voice_assistant_api_connection_() const { void APIConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { if (voice_assistant::global_voice_assistant != nullptr) { - voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe); + voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe, msg.flags); } } void APIConnection::on_voice_assistant_response(const VoiceAssistantResponse &msg) { @@ -1932,7 +1969,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 19; + resp.api_version_minor = 20; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = esphome_version_ref(); resp.name = StringRef(App.get_name()); @@ -2123,6 +2160,14 @@ bool APIConnection::send_device_capabilities_response_() { #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); +#endif +#ifdef USE_STORE_YAML + resp.store_yaml.supported = true; +#endif +#ifdef USE_CRASH_REPORT + CrashReport report; + if (crash_report_get(report)) + resp.crash_report.flags = report.flags; #endif return this->send_message(resp); } @@ -2152,6 +2197,34 @@ void APIConnection::on_device_capabilities_request() { this->on_fatal_error(); } } +#ifdef USE_CRASH_REPORT +void APIConnection::on_crash_report_request() { + CrashReport report; + CrashReportResponse resp; + if (crash_report_get(report)) { + resp.flags = report.flags; + resp.reason = static_cast(report.reason); + resp.pc = report.pc; + resp.cause = report.cause; + resp.fault_address = report.fault_address; + resp.build_time = report.build_time; + resp.crashed_core = report.crashed_core; + for (uint8_t i = 0; i < report.core_count; i++) { + auto &core = resp.cores[i]; + core.core = report.cores[i].core; + core.backtrace = report.cores[i].addresses; + core.backtrace_len = report.cores[i].count; + } + resp.cores_len = report.core_count; + } + if (!this->send_message(resp)) { + this->on_fatal_error(); + return; + } + if (resp.flags & CRASH_REPORT_FLAG_PRESENT) + crash_report_clear(); +} +#endif #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { @@ -2365,10 +2438,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg) { #ifdef HAS_PROTO_MESSAGE_DUMP - // Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise) + // Skip dump for log messages (recursive logging risk), camera frames (high-frequency noise) + // and YAML chunks (may contain secrets) if (message_type != SubscribeLogsResponse::MESSAGE_TYPE #ifdef USE_CAMERA && message_type != CameraImageResponse::MESSAGE_TYPE +#endif +#ifdef USE_STORE_YAML + && message_type != GetYamlResponse::MESSAGE_TYPE #endif ) { auto *proto_msg = static_cast(msg); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 2c986159121..795c7e2e45e 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -32,6 +32,11 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +#ifdef USE_STORE_YAML +/// The zstd YAML envelope generated by store_yaml, STORE_YAML_DATA_SIZE bytes in flash. +extern const uint8_t STORE_YAML_DATA[] PROGMEM; +#endif + // One shared flash string for every refused-frame warning: send_message() // fails as soon as the TCP buffer is full, and each caller only pays for its // short name. The guard drops the helper and its arguments below WARN. @@ -123,6 +128,9 @@ class APIConnection final : public APIServerConnectionBase { void set_camera_state(std::shared_ptr image); void on_camera_image_request(const CameraImageRequest &msg); #endif +#ifdef USE_STORE_YAML + void on_get_yaml_request(); +#endif #ifdef USE_CLIMATE bool send_climate_state(climate::Climate *climate); void on_climate_command_request(const ClimateCommandRequest &msg); @@ -277,6 +285,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_API_WIZARD void on_device_wizard_request(); #endif +#ifdef USE_CRASH_REPORT + void on_crash_report_request(); +#endif #ifdef USE_API_WIZARD_INPUTS void on_wizard_input_set_request(const WizardInputSetRequest &msg); #endif @@ -421,6 +432,13 @@ class APIConnection final : public APIServerConnectionBase { void try_send_camera_image_(); #endif +#ifdef USE_STORE_YAML + void try_send_store_yaml_(); + static constexpr size_t STORE_YAML_IDLE = std::numeric_limits::max(); + // Streaming offset into STORE_YAML_DATA, or STORE_YAML_IDLE + size_t store_yaml_pos_{STORE_YAML_IDLE}; +#endif + #ifdef USE_API_HOMEASSISTANT_STATES void process_state_subscriptions_(); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index acc25610446..eb38d838fe8 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -299,6 +299,19 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_STORE_YAML +uint8_t *StoreYamlCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.supported); + return pos; +} +uint32_t StoreYamlCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_bool(1, msg.supported); + return size; +} +#endif #ifdef USE_API_WIZARD uint8_t *WizardCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -327,6 +340,19 @@ uint32_t SendspinCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_CRASH_REPORT +uint8_t *CrashReportCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.flags); + return pos; +} +uint32_t CrashReportCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.flags); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY @@ -348,6 +374,12 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_SENDSPIN pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); +#endif +#ifdef USE_STORE_YAML + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.store_yaml); +#endif +#ifdef USE_CRASH_REPORT + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.crash_report); #endif return pos; } @@ -373,6 +405,12 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_SENDSPIN size += ProtoSize::calc_message(1, msg.sendspin.calculate_size()); +#endif +#ifdef USE_STORE_YAML + size += ProtoSize::calc_message(1, msg.store_yaml.calculate_size()); +#endif +#ifdef USE_CRASH_REPORT + size += ProtoSize::calc_message(1, msg.crash_report.calculate_size()); #endif return size; } @@ -405,6 +443,54 @@ void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t } } #endif +#ifdef USE_CRASH_REPORT +uint8_t *CrashReportCore::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.core); + for (uint16_t i = 0; i < msg.backtrace_len; i++) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.backtrace[i]); + } + return pos; +} +uint32_t CrashReportCore::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.core); + for (uint16_t i = 0; i < msg.backtrace_len; i++) { + size += ProtoSize::calc_uint32_force(1, msg.backtrace[i]); + } + return size; +} +uint8_t *CrashReportResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.reason); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.pc); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.cause); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.fault_address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.build_time); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.crashed_core); + for (uint16_t i = 0; i < msg.cores_len; i++) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.cores[i]); + } + return pos; +} +uint32_t CrashReportResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_uint32(1, msg.reason); + size += ProtoSize::calc_uint32(1, msg.pc); + size += ProtoSize::calc_uint32(1, msg.cause); + size += ProtoSize::calc_uint32(1, msg.fault_address); + size += ProtoSize::calc_uint32(1, msg.build_time); + size += ProtoSize::calc_uint32(1, msg.crashed_core); + for (uint16_t i = 0; i < msg.cores_len; i++) { + size += ProtoSize::calc_message_force(1, msg.cores[i].calculate_size()); + } + return size; +} +#endif #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -3028,6 +3114,15 @@ uint8_t *VoiceAssistantRequest::encode_msg(const void *self, uint8_t *__restrict pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.audio_settings); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.wake_word_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.wake_word_audio_bytes); + if (uint32_t raw = float_to_raw(msg.wake_word_probability); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + if (uint32_t raw = float_to_raw(msg.wake_word_probability_cutoff); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.wake_word_audio_bytes2); return pos; } uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { @@ -3038,6 +3133,11 @@ uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.flags); size += ProtoSize::calc_message(1, msg.audio_settings.calculate_size()); size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); + size += ProtoSize::calc_length(1, msg.wake_word_id.size()); + size += ProtoSize::calc_uint32(1, msg.wake_word_audio_bytes); + size += ProtoSize::calc_float(1, msg.wake_word_probability); + size += ProtoSize::calc_float(1, msg.wake_word_probability_cutoff); + size += ProtoSize::calc_uint32(1, msg.wake_word_audio_bytes2); return size; } void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -4314,6 +4414,7 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.interface_description); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4325,6 +4426,7 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.interface_number); size += ProtoSize::calc_uint32(1, msg.interface_class); size += ProtoSize::calc_uint32(1, msg.port_number); + size += ProtoSize::calc_length(1, msg.interface_description.size()); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -4407,6 +4509,24 @@ uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_STORE_YAML +uint8_t *GetYamlResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, msg.done ? 0x01 : 0x00); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.total_size); + return pos; +} +uint32_t GetYamlResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_length_force(1, msg.data_len); + size += ProtoSize::calc_bool_force(1); + size += ProtoSize::calc_uint32(1, msg.total_size); + return size; +} +#endif #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index bf300e4c752..3700f897292 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -262,6 +262,7 @@ enum BluetoothScannerMode : uint32_t { enum VoiceAssistantSubscribeFlag : uint32_t { VOICE_ASSISTANT_SUBSCRIBE_NONE = 0, VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1, + VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO = 8, }; enum VoiceAssistantRequestFlag : uint32_t { VOICE_ASSISTANT_REQUEST_NONE = 0, @@ -283,6 +284,7 @@ enum VoiceAssistantEvent : uint32_t { VOICE_ASSISTANT_WAKE_WORD_END = 10, VOICE_ASSISTANT_STT_VAD_START = 11, VOICE_ASSISTANT_STT_VAD_END = 12, + VOICE_ASSISTANT_WAKE_WORD_REJECTED = 13, VOICE_ASSISTANT_TTS_STREAM_START = 98, VOICE_ASSISTANT_TTS_STREAM_END = 99, VOICE_ASSISTANT_INTENT_PROGRESS = 100, @@ -734,6 +736,23 @@ class ZWaveProxyCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_STORE_YAML +class StoreYamlCapabilities final : public ProtoMessage { + public: + bool supported{false}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif #ifdef USE_API_WIZARD class WizardCapabilities final : public ProtoMessage { public: @@ -769,10 +788,27 @@ class SendspinCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_CRASH_REPORT +class CrashReportCapabilities final : public ProtoMessage { + public: + uint32_t flags{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 136; + static constexpr uint8_t ESTIMATED_SIZE = 170; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -793,6 +829,12 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_SENDSPIN SendspinCapabilities sendspin{}; +#endif +#ifdef USE_STORE_YAML + StoreYamlCapabilities store_yaml{}; +#endif +#ifdef USE_CRASH_REPORT + CrashReportCapabilities crash_report{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -863,6 +905,53 @@ class ListEntitiesDoneResponse final : public ProtoMessage { protected: }; +#ifdef USE_CRASH_REPORT +class CrashReportCore final : public ProtoMessage { + public: + uint32_t core{0}; + std::array backtrace{}; + uint16_t backtrace_len{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class CrashReportResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 164; + static constexpr uint8_t ESTIMATED_SIZE = 164; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("crash_report_response"); } +#endif + uint32_t flags{0}; + uint32_t reason{0}; + uint32_t pc{0}; + uint32_t cause{0}; + uint32_t fault_address{0}; + uint32_t build_time{0}; + uint32_t crashed_core{0}; + std::array cores{}; + uint16_t cores_len{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: @@ -2953,7 +3042,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { class VoiceAssistantRequest final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 90; - static constexpr uint8_t ESTIMATED_SIZE = 41; + static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } #endif @@ -2962,6 +3051,11 @@ class VoiceAssistantRequest final : public ProtoMessage { uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; StringRef wake_word_phrase{nullptr, 0}; // null until set, encode only + StringRef wake_word_id{nullptr, 0}; // null until set, encode only + uint32_t wake_word_audio_bytes{0}; + float wake_word_probability{0.0f}; + float wake_word_probability_cutoff{0.0f}; + uint32_t wake_word_audio_bytes2{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -3841,7 +3935,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage { uint32_t device_id{0}; #endif uint32_t key{0}; - const std::vector *timings{}; + const std::span *timings{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -4102,6 +4196,7 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t interface_number{0}; uint32_t interface_class{0}; uint32_t port_number{0}; + StringRef interface_description{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -4209,5 +4304,30 @@ class SendspinPairingTokenResponse final : public ProtoMessage { protected: }; #endif +#ifdef USE_STORE_YAML +class GetYamlResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 162; + static constexpr uint8_t ESTIMATED_SIZE = 25; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("get_yaml_response"); } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + bool done{false}; + uint32_t total_size{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 60b64b41838..084ec62e5a5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -675,6 +675,8 @@ const char *proto_enum_to_string(enums::Voic return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_NONE"); case enums::VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO: return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO"); + case enums::VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO: + return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -720,6 +722,8 @@ template<> const char *proto_enum_to_string(enums::V return ESPHOME_PSTR("VOICE_ASSISTANT_STT_VAD_START"); case enums::VOICE_ASSISTANT_STT_VAD_END: return ESPHOME_PSTR("VOICE_ASSISTANT_STT_VAD_END"); + case enums::VOICE_ASSISTANT_WAKE_WORD_REJECTED: + return ESPHOME_PSTR("VOICE_ASSISTANT_WAKE_WORD_REJECTED"); case enums::VOICE_ASSISTANT_TTS_STREAM_START: return ESPHOME_PSTR("VOICE_ASSISTANT_TTS_STREAM_START"); case enums::VOICE_ASSISTANT_TTS_STREAM_END: @@ -1127,6 +1131,13 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_STORE_YAML +const char *StoreYamlCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("StoreYamlCapabilities")); + dump_field(out, ESPHOME_PSTR("supported"), this->supported); + return out.c_str(); +} +#endif #ifdef USE_API_WIZARD const char *WizardCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities")); @@ -1142,6 +1153,13 @@ const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_CRASH_REPORT +const char *CrashReportCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportCapabilities")); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1175,6 +1193,16 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); this->sendspin.dump_to(out); out.append("\n"); +#endif +#ifdef USE_STORE_YAML + out.append(2, ' ').append_p(ESPHOME_PSTR("store_yaml")).append(": "); + this->store_yaml.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_CRASH_REPORT + out.append(2, ' ').append_p(ESPHOME_PSTR("crash_report")).append(": "); + this->crash_report.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -1197,6 +1225,32 @@ const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); } +#ifdef USE_CRASH_REPORT +const char *CrashReportCore::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportCore")); + dump_field(out, ESPHOME_PSTR("core"), this->core); + for (uint16_t i = 0; i < this->backtrace_len; i++) { + dump_field(out, ESPHOME_PSTR("backtrace"), this->backtrace[i], 4); + } + return out.c_str(); +} +const char *CrashReportResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportResponse")); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + dump_field(out, ESPHOME_PSTR("reason"), this->reason); + dump_field(out, ESPHOME_PSTR("pc"), this->pc); + dump_field(out, ESPHOME_PSTR("cause"), this->cause); + dump_field(out, ESPHOME_PSTR("fault_address"), this->fault_address); + dump_field(out, ESPHOME_PSTR("build_time"), this->build_time); + dump_field(out, ESPHOME_PSTR("crashed_core"), this->crashed_core); + for (uint16_t i = 0; i < this->cores_len; i++) { + out.append(4, ' ').append_p(ESPHOME_PSTR("cores")).append(": "); + this->cores[i].dump_to(out); + out.append("\n"); + } + return out.c_str(); +} +#endif #ifdef USE_BINARY_SENSOR const char *ListEntitiesBinarySensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesBinarySensorResponse")); @@ -2391,6 +2445,11 @@ const char *VoiceAssistantRequest::dump_to(DumpBuffer &out) const { this->audio_settings.dump_to(out); out.append("\n"); dump_field(out, ESPHOME_PSTR("wake_word_phrase"), this->wake_word_phrase); + dump_field(out, ESPHOME_PSTR("wake_word_id"), this->wake_word_id); + dump_field(out, ESPHOME_PSTR("wake_word_audio_bytes"), this->wake_word_audio_bytes); + dump_field(out, ESPHOME_PSTR("wake_word_probability"), this->wake_word_probability); + dump_field(out, ESPHOME_PSTR("wake_word_probability_cutoff"), this->wake_word_probability_cutoff); + dump_field(out, ESPHOME_PSTR("wake_word_audio_bytes2"), this->wake_word_audio_bytes2); return out.c_str(); } const char *VoiceAssistantResponse::dump_to(DumpBuffer &out) const { @@ -2975,6 +3034,7 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); + dump_field(out, ESPHOME_PSTR("interface_description"), this->interface_description); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { @@ -3016,6 +3076,15 @@ const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_STORE_YAML +const char *GetYamlResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("GetYamlResponse")); + dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + dump_field(out, ESPHOME_PSTR("done"), this->done); + dump_field(out, ESPHOME_PSTR("total_size"), this->total_size); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index e076f98fe3d..239b919c4e9 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -27,6 +27,10 @@ #include "esphome/components/select/select_traits.h" #endif +#ifdef USE_CRASH_REPORT +#include "esphome/core/crash_report.h" +#endif + // Standard library includes that might be needed #include #include diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 683bb84c302..0f21858fc8c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -760,6 +760,24 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_sendspin_pairing_token_request(); break; } +#endif +#ifdef USE_STORE_YAML + case 161 /* GetYamlRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_get_yaml_request")); +#endif + this->on_get_yaml_request(); + break; + } +#endif +#ifdef USE_CRASH_REPORT + case 163 /* CrashReportRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_crash_report_request")); +#endif + this->on_crash_report_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index b4c39e022b3..3e7f7254f46 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -39,6 +39,9 @@ class APIServerConnectionBase { void on_list_entities_request() {}; void on_subscribe_states_request() {}; +#ifdef USE_CRASH_REPORT + void on_crash_report_request() {}; +#endif #ifdef USE_COVER void on_cover_command_request(const CoverCommandRequest &value) {}; @@ -256,6 +259,10 @@ class APIServerConnectionBase { #ifdef USE_SENDSPIN void on_sendspin_pairing_token_request() {}; #endif + +#ifdef USE_STORE_YAML + void on_get_yaml_request() {}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 91ab7bd00ac..8fe148b073b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -520,13 +520,13 @@ void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) { #ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, - const std::vector *timings) { + std::span timings) { InfraredRFReceiveEvent resp{}; #ifdef USE_DEVICES resp.device_id = device_id; #endif resp.key = key; - resp.timings = timings; + resp.timings = &timings; for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 52310747fcf..e41cddd3e80 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -30,6 +30,7 @@ #include #include +#include #include namespace esphome::api { @@ -208,7 +209,7 @@ class APIServer final : public Component void send_serial_proxy_identity(const SerialProxyIdentity &msg); #endif #ifdef USE_IR_RF - void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); + void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, std::span timings); #endif bool is_connected() const { return this->api_connection_count_ != 0; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index a42166353d4..183382653e4 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -160,17 +160,14 @@ void spi_dma_tx_finish_callback(unsigned int param) { void BekenSPILEDStripLightOutput::setup() { RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } const size_t dma_buffer_size = (this->get_buffer_size_() * 8) + (2 * 64); this->dma_buf_ = allocator.allocate(dma_buffer_size); - if (this->dma_buf_ == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->dma_buf_ != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } memset(this->dma_buf_, 0, dma_buffer_size); @@ -180,22 +177,16 @@ void BekenSPILEDStripLightOutput::setup() { value = PWD_SPI_CLK_BIT; sddev_control(ICU_DEV_NAME, CMD_CLK_PWR_UP, &value); - if (spi_data != nullptr) { - this->mark_failed(LOG_STR("SPI already initialized")); + if (!this->setup_check(spi_data == nullptr, LOG_STR("SPI already initialized"))) return; - } spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) - if (spi_data == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(spi_data != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } spi_data->dma_tx_semaphore = xSemaphoreCreateBinary(); - if (spi_data->dma_tx_semaphore == nullptr) { - this->mark_failed(LOG_STR("TX semaphore init failed")); + if (!this->setup_check(spi_data->dma_tx_semaphore != nullptr, LOG_STR("TX semaphore init failed"))) return; - } spi_data->first_run = true; diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 36b92ea9efb..1822f8403b7 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -193,12 +193,54 @@ void BME680BSECComponent::loop() { } this->status_comm_result(this->bsec_status_ <= BSEC_OK && this->bme680_status_ <= BME680_OK, nullptr); - // Process a single action from the queue. These are primarily sensor state publishes - // that in totality take too long to send in a single call. - if (this->queue_.size()) { - auto action = std::move(this->queue_.front()); - this->queue_.pop(); - action(); + // Publish a single waiting state. All of them in one call take too long. + if (this->pending_.empty()) + return; + const PublishSlot slot = this->pending_.front(); + this->pending_.pop(); + this->pending_mask_.erase(slot); + switch (slot) { + case PUBLISH_SLOT_IAQ: + this->publish_sensor_(this->iaq_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_IAQ_ACCURACY_TEXT: + this->publish_sensor_( + this->iaq_accuracy_text_sensor_, + IaqAccuracyStrings::get_progmem_str(this->pending_accuracy_, IaqAccuracyStrings::LAST_INDEX)); + break; + case PUBLISH_SLOT_IAQ_ACCURACY: + this->publish_sensor_(this->iaq_accuracy_sensor_, this->pending_accuracy_, true); + break; + case PUBLISH_SLOT_SAVE_STATE: + this->save_state_(this->pending_accuracy_); + break; + case PUBLISH_SLOT_CO2_EQUIVALENT: + this->publish_sensor_(this->co2_equivalent_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_BREATH_VOC_EQUIVALENT: + this->publish_sensor_(this->breath_voc_equivalent_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_PRESSURE: + this->publish_sensor_(this->pressure_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_GAS_RESISTANCE: + this->publish_sensor_(this->gas_resistance_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_TEMPERATURE: + this->publish_sensor_(this->temperature_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_HUMIDITY: + this->publish_sensor_(this->humidity_sensor_, this->pending_values_[slot]); + break; + default: + break; + } +} + +void BME680BSECComponent::queue_publish_(PublishSlot slot) { + if (!this->pending_mask_.count(slot)) { + this->pending_mask_.insert(slot); + this->pending_.push(slot); } } @@ -385,35 +427,31 @@ void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_out float signal = outputs[i].signal; switch (outputs[i].sensor_id) { case BSEC_OUTPUT_IAQ: - case BSEC_OUTPUT_STATIC_IAQ: { - uint8_t accuracy = std::min(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX); - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); }); - this->queue_push_([this, accuracy]() { - this->publish_sensor_(this->iaq_accuracy_text_sensor_, - IaqAccuracyStrings::get_progmem_str(accuracy, IaqAccuracyStrings::LAST_INDEX)); - }); - this->queue_push_([this, accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, accuracy, true); }); - + case BSEC_OUTPUT_STATIC_IAQ: + this->pending_accuracy_ = std::min(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX); + this->queue_publish_(signal); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY_TEXT); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY); // Queue up an opportunity to save state - this->queue_push_([this, accuracy]() { this->save_state_(accuracy); }); - } break; + this->queue_publish_(PUBLISH_SLOT_SAVE_STATE); + break; case BSEC_OUTPUT_CO2_EQUIVALENT: - this->queue_push_([this, signal]() { this->publish_sensor_(this->co2_equivalent_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_BREATH_VOC_EQUIVALENT: - this->queue_push_([this, signal]() { this->publish_sensor_(this->breath_voc_equivalent_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_RAW_PRESSURE: - this->queue_push_([this, signal]() { this->publish_sensor_(this->pressure_sensor_, signal / 100.0f); }); + this->queue_publish_(signal / 100.0f); break; case BSEC_OUTPUT_RAW_GAS: - this->queue_push_([this, signal]() { this->publish_sensor_(this->gas_resistance_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE: - this->queue_push_([this, signal]() { this->publish_sensor_(this->temperature_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY: - this->queue_push_([this, signal]() { this->publish_sensor_(this->humidity_sensor_, signal); }); + this->queue_publish_(signal); break; } } diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 3670866912f..276ab9af256 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -6,8 +6,9 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/preferences.h" #include "esphome/core/defines.h" +#include "esphome/core/finite_set_mask.h" +#include "esphome/core/helpers.h" #include -#include #ifdef USE_BSEC #include @@ -67,6 +68,22 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { void loop() override; protected: + // States waiting for loop() to publish them, one per pass + enum PublishSlot : uint8_t { + PUBLISH_SLOT_IAQ, + PUBLISH_SLOT_CO2_EQUIVALENT, + PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, + PUBLISH_SLOT_PRESSURE, + PUBLISH_SLOT_GAS_RESISTANCE, + PUBLISH_SLOT_TEMPERATURE, + PUBLISH_SLOT_HUMIDITY, + // These take pending_accuracy_ instead of a value + PUBLISH_SLOT_IAQ_ACCURACY_TEXT, + PUBLISH_SLOT_IAQ_ACCURACY, + PUBLISH_SLOT_SAVE_STATE, + PUBLISH_SLOT_COUNT, + }; + void set_config_(); float calc_sensor_sample_rate_(SampleRate sample_rate); void update_subscription_(); @@ -91,7 +108,12 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { void save_state_( uint8_t accuracy); // Save the bsec_state_data_ member (volatile memory) to the ESP preferences (storage) - void queue_push_(std::function &&f) { this->queue_.push(std::move(f)); } + void queue_publish_(PublishSlot slot); + template void queue_publish_(float value) { + static_assert(S < PUBLISH_SLOT_IAQ_ACCURACY_TEXT, "slot has no value"); + this->pending_values_[S] = value; + this->queue_publish_(S); + } static uint8_t work_buffer_[BSEC_MAX_WORKBUFFER_SIZE]; struct bme680_dev bme680_; @@ -102,7 +124,11 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { uint32_t millis_overflow_counter_{0}; int64_t next_call_ns_{0}; - std::queue> queue_; + // Arrival order; a slot already waiting keeps its place and takes the newer value + StaticRingBuffer pending_; + FiniteSetMask> pending_mask_; + float pending_values_[PUBLISH_SLOT_IAQ_ACCURACY_TEXT]; + uint8_t pending_accuracy_{0}; bool bsec_state_data_valid_; uint8_t bsec_state_data_[BSEC_MAX_STATE_BLOB_SIZE]; // This is the current snapshot of the BSEC library state diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index 1be5c3835e4..c973ae3bf43 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -130,14 +130,62 @@ void BME68xBSEC2Component::check_status_() { } void BME68xBSEC2Component::loop() { - // Process a single action from the queue. These are primarily sensor state publishes - // that in totality take too long to send in a single call. - if (this->queue_.size()) { - auto action = std::move(this->queue_.front()); - this->queue_.pop(); - action(); - } else { + // Publish a single waiting state. All of them in one call take too long. + if (this->pending_.empty()) { this->disable_loop(); + return; + } + const PendingPublish pending = this->pending_.front(); + this->pending_.pop(); + const float value = pending.value; + switch (pending.slot) { +#ifdef USE_SENSOR + case PUBLISH_SLOT_IAQ: + this->publish_sensor_(this->iaq_sensor_, value); + break; + case PUBLISH_SLOT_IAQ_STATIC: + this->publish_sensor_(this->iaq_static_sensor_, value); + break; + case PUBLISH_SLOT_CO2_EQUIVALENT: + this->publish_sensor_(this->co2_equivalent_sensor_, value); + break; + case PUBLISH_SLOT_BREATH_VOC_EQUIVALENT: + this->publish_sensor_(this->breath_voc_equivalent_sensor_, value); + break; + case PUBLISH_SLOT_PRESSURE: + this->publish_sensor_(this->pressure_sensor_, value); + break; + case PUBLISH_SLOT_GAS_RESISTANCE: + this->publish_sensor_(this->gas_resistance_sensor_, value); + break; + case PUBLISH_SLOT_TEMPERATURE: + this->publish_sensor_(this->temperature_sensor_, value); + break; + case PUBLISH_SLOT_HUMIDITY: + this->publish_sensor_(this->humidity_sensor_, value); + break; + case PUBLISH_SLOT_IAQ_ACCURACY: + this->publish_sensor_(this->iaq_accuracy_sensor_, value, true); + break; +#endif +#ifdef USE_TEXT_SENSOR + case PUBLISH_SLOT_IAQ_ACCURACY_TEXT: + this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[static_cast(value)]); + break; +#endif + case PUBLISH_SLOT_SAVE_STATE: + this->save_state_(static_cast(value)); + break; + default: + break; + } +} + +void BME68xBSEC2Component::queue_publish_(PublishSlot slot, float value) { + // Only full when the loop stalled for seconds; drop the oldest so the newest reading still goes out + if (!this->pending_.push({value, slot})) { + this->pending_.pop(); + this->pending_.push({value, slot}); } } @@ -414,44 +462,44 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou max_accuracy = std::max(outputs[i].accuracy, max_accuracy); update_accuracy = true; #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_IAQ, signal); #endif break; case BSEC_OUTPUT_STATIC_IAQ: max_accuracy = std::max(outputs[i].accuracy, max_accuracy); update_accuracy = true; #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_static_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_IAQ_STATIC, signal); #endif break; case BSEC_OUTPUT_CO2_EQUIVALENT: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->co2_equivalent_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_CO2_EQUIVALENT, signal); #endif break; case BSEC_OUTPUT_BREATH_VOC_EQUIVALENT: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->breath_voc_equivalent_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, signal); #endif break; case BSEC_OUTPUT_RAW_PRESSURE: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->pressure_sensor_, signal / 100.0f); }); + this->queue_publish_(PUBLISH_SLOT_PRESSURE, signal / 100.0f); #endif break; case BSEC_OUTPUT_RAW_GAS: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->gas_resistance_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_GAS_RESISTANCE, signal); #endif break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->temperature_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_TEMPERATURE, signal); #endif break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->humidity_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_HUMIDITY, signal); #endif break; } @@ -459,16 +507,13 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou if (update_accuracy) { max_accuracy = std::min(max_accuracy, std::size(IAQ_ACCURACY_STATES) - 1); #ifdef USE_SENSOR - this->queue_push_( - [this, max_accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, max_accuracy, true); }); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY, max_accuracy); #endif #ifdef USE_TEXT_SENSOR - this->queue_push_([this, max_accuracy]() { - this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[max_accuracy]); - }); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY_TEXT, max_accuracy); #endif // Queue up an opportunity to save state - this->queue_push_([this, max_accuracy]() { this->save_state_(max_accuracy); }); + this->queue_publish_(PUBLISH_SLOT_SAVE_STATE, max_accuracy); } this->enable_loop(); } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index deb83e81b26..7893ae381d3 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #ifdef USE_BSEC2 @@ -15,7 +16,6 @@ #endif #include -#include #include @@ -83,6 +83,28 @@ class BME68xBSEC2Component : public Component { virtual uint32_t get_hash() = 0; protected: + // States waiting for loop() to publish them, one per pass + enum PublishSlot : uint8_t { + PUBLISH_SLOT_IAQ, + PUBLISH_SLOT_IAQ_STATIC, + PUBLISH_SLOT_CO2_EQUIVALENT, + PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, + PUBLISH_SLOT_PRESSURE, + PUBLISH_SLOT_GAS_RESISTANCE, + PUBLISH_SLOT_TEMPERATURE, + PUBLISH_SLOT_HUMIDITY, + PUBLISH_SLOT_IAQ_ACCURACY, + PUBLISH_SLOT_IAQ_ACCURACY_TEXT, + PUBLISH_SLOT_SAVE_STATE, + PUBLISH_SLOT_COUNT, + }; + struct __attribute__((packed)) PendingPublish { + float value; // the accuracy for the accuracy and save slots + PublishSlot slot; + }; + // A read returns up to 3 fields in parallel mode, each published in full + static constexpr uint8_t PUBLISH_QUEUE_SIZE = 3 * PUBLISH_SLOT_COUNT; + void set_config_(const uint8_t *config, u_int32_t len); float calc_sensor_sample_rate_(SampleRate sample_rate); void update_subscription_(); @@ -103,14 +125,14 @@ class BME68xBSEC2Component : public Component { void load_state_(); void save_state_(uint8_t accuracy); - void queue_push_(std::function &&f) { this->queue_.push(std::move(f)); } + void queue_publish_(PublishSlot slot, float value); struct bme68x_dev bme68x_; bsec_bme_settings_t bsec_settings_; bsec_version_t version_; struct bme68x_heatr_conf bme68x_heatr_conf_; - std::queue> queue_; + StaticRingBuffer pending_; ESPPreferenceObject bsec_state_; uint8_t const *bsec2_configuration_{nullptr}; diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index e5089acb641..5e0b2bddde2 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -45,10 +45,8 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { void BMP3XXComponent::setup() { // Call the Device base class "initialise" function - if (!reset()) { - this->mark_failed(LOG_STR("Failed to reset")); + if (!this->setup_check(this->reset(), LOG_STR("Failed to reset"))) return; - } if (!this->setup_check(read_byte(BMP388_CHIP_ID, &this->chip_id_.reg))) return; @@ -82,10 +80,8 @@ void BMP3XXComponent::setup() { compensation_float_params_.param_P11 = (float) compensation_params_.param_P11 / powf(2.0f, 65.0f); // Initialise the BMP388 IIR filter register - if (!set_iir_filter(this->iir_filter_)) { - this->mark_failed(LOG_STR("Failed to set IIR filter")); + if (!this->setup_check(this->set_iir_filter(this->iir_filter_), LOG_STR("Failed to set IIR filter"))) return; - } // Set power control registers pwr_ctrl_.bit.press_en = 1; @@ -101,10 +97,8 @@ void BMP3XXComponent::setup() { this->temperature_oversampling_ = OVERSAMPLING_NONE; } // Initialise the BMP388 oversampling register - if (!set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_)) { - this->mark_failed(LOG_STR("Failed to set oversampling register")); - return; - } + this->setup_check(this->set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_), + LOG_STR("Failed to set oversampling register")); } void BMP3XXComponent::dump_config() { diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 78df18fb042..5bd7c661e95 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -65,19 +65,14 @@ BQ25186Component = bq25186_ns.class_( BQ25186_CHILD_SCHEMA = cv.Schema( {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} ) -_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") -_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") - - -def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: - _request_binary_sensor_slot(str(hub)) - return hub.register_binary_sensor(var) - - -def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: - _request_text_sensor_slot(str(hub)) - return hub.register_text_sensor(var) - +BQ25186StatusRegisters = bq25186_ns.class_("BQ25186StatusRegisters") +STATUS_PUBLISHER = cg.HubFunction( + "status", + cg.void, + [(BQ25186StatusRegisters.const_ref(), "status")], + define="USE_BQ25186_STATUS_PUBLISHER", +) +STATUS = STATUS_PUBLISHER.param("status") REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py index fb741950e29..ec8f773d0c2 100644 --- a/esphome/components/bq25186/binary_sensor.py +++ b/esphome/components/bq25186/binary_sensor.py @@ -1,5 +1,4 @@ -from functools import partial - +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv @@ -9,16 +8,13 @@ from esphome.const import ( DEVICE_CLASS_PLUG, DEVICE_CLASS_RUNNING, ) +from esphome.cpp_generator import HexIntLiteral from esphome.types import ConfigType -from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, STATUS, STATUS_PUBLISHER DEPENDENCIES = ["bq25186"] -BQ25186BinarySensor = bq25186_ns.class_( - "BQ25186BinarySensor", binary_sensor.BinarySensor -) - REG_STAT0 = 0 REG_STAT1 = 1 REG_FLAG0 = 2 @@ -51,11 +47,19 @@ _FIELDS = ( ) +def _state_expression(reg: int, shift: int, mask: int, match: int) -> cg.MockObj: + """Whether the field (status[reg] >> shift) & mask reads as on, as a bit of match.""" + value = (STATUS[reg] >> shift) & HexIntLiteral(mask) + if mask == 0x01 and match == _BIT_SET: + return value + return (cg.MockObj(f"{match:#06b}") >> value) & 1 + + CONFIG_SCHEMA = cv.All( BQ25186_CHILD_SCHEMA.extend( { cv.Optional(key): binary_sensor.binary_sensor_schema( - BQ25186BinarySensor, device_class=device_class + device_class=device_class ) for key, *_, device_class in _FIELDS } @@ -68,6 +72,6 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BQ25186_ID]) sensors = binary_sensor.sub_binary_sensors(config) for key, reg, shift, mask, match, _ in _FIELDS: - await sensors( - key, partial(register_binary_sensor, hub), reg, shift, mask, match - ) + if (var := await sensors(key, None)) is not None: + state = _state_expression(reg, shift, mask, match) + STATUS_PUBLISHER.add(hub, parent_ref(var).publish_state(state)) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index 567a580bdea..f91123e7c36 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,10 +1,4 @@ #include "bq25186.h" -#ifdef USE_BINARY_SENSOR -#include "bq25186_binary_sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "bq25186_text_sensor.h" -#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -144,13 +138,9 @@ void BQ25186Component::update() { #endif if (!pending) this->status_clear_warning(); -#ifdef BQ25186_BINARY_SENSOR_COUNT - for (auto *sensor : this->binary_sensors_) - sensor->on_status(status); -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - for (auto *sensor : this->text_sensors_) - sensor->on_status(status); +#ifdef USE_BQ25186_STATUS_PUBLISHER + if (this->status_publisher_ != nullptr) + this->status_publisher_(status); #endif } diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index 7b3092d86ce..5f3420e47a8 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -7,6 +7,10 @@ #ifdef USE_BQ25186_PG_GPO #include "esphome/components/switch/switch.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/progmem.h" +#endif #include @@ -38,8 +42,21 @@ static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; using BQ25186ConfigRegisters = std::array; using BQ25186StatusRegisters = std::array; -class BQ25186BinarySensor; -class BQ25186TextSensor; +#ifdef USE_TEXT_SENSOR +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(BQ25186StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", + "Charge Done/Disabled", "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", + "Warm (Voltage Reduced)"); + +/// Publish the two-bit field in the low bits of `value` as string `first + field`, only when it differs from `last` +inline void publish_status_text(text_sensor::TextSensor *sensor, uint8_t &last, uint8_t value, uint8_t first) { + value &= 0x03; + if (value == last) + return; + last = value; + sensor->publish_state(BQ25186StatusStrings::get_progmem_str(first + value, BQ25186StatusStrings::LAST_INDEX)); +} +#endif class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: @@ -52,11 +69,8 @@ class BQ25186Component : public PollingComponent, public i2c::I2CDevice { this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); -#ifdef BQ25186_BINARY_SENSOR_COUNT - void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#ifdef USE_BQ25186_STATUS_PUBLISHER + HUB_PUBLISHER(status, const BQ25186StatusRegisters &) #endif #ifdef USE_BQ25186_PG_GPO /// The /PG/GPO pin is put in general purpose output mode when a switch drives it @@ -78,12 +92,6 @@ class BQ25186Component : public PollingComponent, public i2c::I2CDevice { void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); #endif -#ifdef BQ25186_BINARY_SENSOR_COUNT - StaticVector binary_sensors_; -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - StaticVector text_sensors_; -#endif BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; bool configured_{false}; // regs_ went to the chip at least once diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h deleted file mode 100644 index 265f34a4459..00000000000 --- a/esphome/components/bq25186/bq25186_binary_sensor.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_BINARY_SENSOR - -#include "bq25186.h" -#include "esphome/components/binary_sensor/binary_sensor.h" - -namespace esphome::bq25186 { - -/// One field of a status register; `match` has a bit set for each field value that reads as on -class BQ25186BinarySensor final : public binary_sensor::BinarySensor { - public: - BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) - : reg_(reg), shift_(shift), mask_(mask), match_(match) {} - - void on_status(const BQ25186StatusRegisters &status) { - const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; - this->publish_state((this->match_ >> value) & 1); - } - - protected: - const uint8_t reg_; - const uint8_t shift_; - const uint8_t mask_; - const uint8_t match_; -}; - -} // namespace esphome::bq25186 - -#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp deleted file mode 100644 index f34d6ebdc0a..00000000000 --- a/esphome/components/bq25186/bq25186_text_sensor.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include "bq25186_text_sensor.h" -#ifdef USE_TEXT_SENSOR - -#include "esphome/core/progmem.h" - -namespace esphome::bq25186 { - -// STAT0[6:5] charge status, then STAT1[4:3] TS status -PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", - "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); - -void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { - const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; - if (value == this->last_) - return; - this->last_ = value; - this->publish_state(StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX)); -} - -} // namespace esphome::bq25186 - -#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h deleted file mode 100644 index 33c8f806fa9..00000000000 --- a/esphome/components/bq25186/bq25186_text_sensor.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_TEXT_SENSOR - -#include "bq25186.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::bq25186 { - -/// A two-bit status field; `first` is the string table index of the field's value 0 -class BQ25186TextSensor final : public text_sensor::TextSensor { - public: - BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} - void on_status(const BQ25186StatusRegisters &status); - - protected: - const uint8_t reg_; - const uint8_t shift_; - const uint8_t first_; - uint8_t last_{0xFF}; // the field value last published -}; - -} // namespace esphome::bq25186 - -#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py index b27d2fbcfae..e8804bca5a5 100644 --- a/esphome/components/bq25186/text_sensor.py +++ b/esphome/components/bq25186/text_sensor.py @@ -1,16 +1,24 @@ -from functools import partial - +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.cpp_generator import ( + AssignmentExpression, + HexIntLiteral, + VariableDeclarationExpression, +) from esphome.types import ConfigType -from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor +from . import ( + BQ25186_CHILD_SCHEMA, + CONF_BQ25186_ID, + STATUS, + STATUS_PUBLISHER, + bq25186_ns, +) DEPENDENCIES = ["bq25186"] -BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) - REG_STAT0 = 0 REG_STAT1 = 1 @@ -20,12 +28,28 @@ _FIELDS = ( ("ts_status", REG_STAT1, 3, 4), ) + +def _add_text_status( + hub: cg.MockObj, var: cg.MockObj, reg: int, shift: int, first: int +) -> None: + """Publish status string first + the two-bit field at status[reg] >> shift, on change.""" + last = cg.MockObj(f"esphome__{var}__last") + # 0xFF so the first poll publishes + declaration = VariableDeclarationExpression(cg.uint8, "", last, static=True) + STATUS_PUBLISHER.add( + hub, AssignmentExpression(None, None, declaration, HexIntLiteral(0xFF)) + ) + STATUS_PUBLISHER.add( + hub, + bq25186_ns.publish_status_text( + parent_ref(var), last, STATUS[reg] >> shift, first + ), + ) + + CONFIG_SCHEMA = cv.All( BQ25186_CHILD_SCHEMA.extend( - { - cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) - for key, *_ in _FIELDS - } + {cv.Optional(key): text_sensor.text_sensor_schema() for key, *_ in _FIELDS} ), cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), ) @@ -35,4 +59,5 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BQ25186_ID]) sensors = text_sensor.sub_text_sensors(config) for key, reg, shift, first in _FIELDS: - await sensors(key, partial(register_text_sensor, hub), reg, shift, first) + if (var := await sensors(key, None)) is not None: + _add_text_status(hub, var, reg, shift, first) diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index fe78162c650..4fb814b8103 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -10,6 +10,8 @@ CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] DOMAIN = "bthome_mithermometer" +CONF_REPLAY_PROTECTION = "replay_protection" + bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( @@ -29,14 +31,25 @@ def bthome_mithermometer_base_schema( cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_REPLAY_PROTECTION, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) .extend(ble_device_base.BLE_DEVICE_SCHEMA), + _validate_replay_protection, ) +def _validate_replay_protection(config: ConfigType) -> ConfigType: + if config[CONF_REPLAY_PROTECTION] and CONF_BINDKEY not in config: + raise cv.Invalid( + f"'{CONF_REPLAY_PROTECTION}' requires '{CONF_BINDKEY}'", + path=[CONF_REPLAY_PROTECTION], + ) + return config + + async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) @@ -47,3 +60,6 @@ async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: for index in range(0, len(bindkey), 2) ] cg.add(var.set_bindkey(cg.ArrayInitializer(*bindkey_bytes))) + if config[CONF_REPLAY_PROTECTION]: + cg.add_define("USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION") + cg.add(var.set_replay_protection(True)) diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 17982d77b40..776d29b97f0 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -1,10 +1,12 @@ #include "bthome_ble.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include +#include #include #include @@ -31,6 +33,9 @@ static constexpr size_t BTHOME_BINDKEY_SIZE = 16; static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; static constexpr size_t BTHOME_COUNTER_SIZE = 4; +// If no frame with a higher counter has been accepted for this long, a lower +// counter is accepted again (the sensor rebooted, e.g. after a battery swap). +static constexpr uint32_t BTHOME_REPLAY_RESYNC_MS = 10 * 60 * 1000; // Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they // compile away and an ungated helper trips -Wunused-function. @@ -156,6 +161,9 @@ void BTHomeMiThermometer::dump_config() { if (this->has_bindkey_) { char bindkey_hex[format_hex_pretty_size(BTHOME_BINDKEY_SIZE)]; ESP_LOGCONFIG(TAG, " Bindkey: %s", format_hex_pretty_to(bindkey_hex, this->bindkey_, BTHOME_BINDKEY_SIZE, '.')); +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + ESP_LOGCONFIG(TAG, " Replay Protection: %s", YESNO(this->replay_protection_)); +#endif } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); @@ -253,6 +261,40 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return true; } +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION +// PVVX sends new data under an unchanged counter, so only an exact repeat (counter and MIC) is a duplicate and +// earlier frames under the current counter can be replayed; the lower-counter rule is the defence. It holds only +// while the sensor keeps being heard: after the resync window one old frame becomes the baseline and recorded +// frames above it can follow in order, the price of recovering from a counter restart. RAM only. +bool BTHomeMiThermometer::check_replay_counter_(uint32_t counter, uint32_t mic) { + const uint32_t now = App.get_loop_component_start_time(); + if (counter == this->last_counter_ && mic == this->last_mic_) { + ESP_LOGVV(TAG, "Duplicate BTHome frame (counter %" PRIu32 ")", counter); + return false; + } + if (counter < this->last_counter_) { + if (now - this->last_accepted_ms_ < BTHOME_REPLAY_RESYNC_MS) { + // One warning per run of rejections + if (this->replay_rejected_) { + ESP_LOGVV(TAG, "BTHome frame rejected: counter %" PRIu32 " lower than last %" PRIu32, counter, + this->last_counter_); + } else { + ESP_LOGW(TAG, "BTHome frame rejected: counter %" PRIu32 " lower than last %" PRIu32 " (replay?)", counter, + this->last_counter_); + this->replay_rejected_ = true; + } + return false; + } + ESP_LOGW(TAG, "BTHome counter restarted (%" PRIu32 " -> %" PRIu32 "), resync", this->last_counter_, counter); + } + this->last_counter_ = counter; + this->last_mic_ = mic; + this->last_accepted_ms_ = now; + this->replay_rejected_ = false; + return true; +} +#endif + bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { @@ -339,6 +381,17 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat return false; } +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + // After the MIC and address checks, so other devices never touch the state + if (is_encrypted && this->replay_protection_) { + const uint8_t *tail = data.data() + data.size() - BTHOME_COUNTER_SIZE - BTHOME_MIC_SIZE; + if (!this->check_replay_counter_(encode_value_le(tail), + encode_value_le(tail + BTHOME_COUNTER_SIZE))) { + return false; + } + } +#endif + if (payload_size == 0) { ESP_LOGVV(TAG, "BTHome payload empty after header"); return false; diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 0978e569511..cd222225083 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -13,6 +13,10 @@ // decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { +namespace testing { +class ReplayClockAccess; +} // namespace testing + class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { SUB_SENSOR(temperature) SUB_SENSOR(humidity) @@ -23,18 +27,36 @@ class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, p public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + void set_replay_protection(bool replay_protection) { this->replay_protection_ = replay_protection; } +#endif void dump_config() override; bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + friend class testing::ReplayClockAccess; +#endif bool handle_service_data_(const ble_device_base::ServiceData &service_data, const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + bool check_replay_counter_(uint32_t counter, uint32_t mic); +#endif uint64_t address_{0}; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + uint32_t last_counter_{0}; + uint32_t last_mic_{0}; + uint32_t last_accepted_ms_{0}; +#endif optional last_packet_id_{}; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + bool replay_protection_{false}; + bool replay_rejected_{false}; +#endif bool has_bindkey_{false}; uint8_t bindkey_[16]; }; diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 27220051a77..f099105bd12 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -4,10 +4,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.config import iter_ids import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,8 +26,25 @@ CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +CONF_FD = "fd" +CONF_BIT_RATE_SWITCH = "bit_rate_switch" +CONF_PAD_DATA = "pad_data" +CONF_DEFAULT_FD = "default_fd" +CONF_DEFAULT_BIT_RATE_SWITCH = "default_bit_rate_switch" +CONF_DEFAULT_PAD_DATA = "default_pad_data" DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h +# All 29 id bits; received ids never use more, so on_frame skips the & for this mask +CAN_ID_MASK_ALL = 0x1FFFFFFF +CAN_MAX_DATA_LENGTH = 8 +# Payload length of each CAN FD DLC, keep in sync with CAN_FD_DLC_LENGTHS in canbus.h +CAN_FD_DATA_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) +# canbus.send options that need a platform built on CANBUS_FD_SCHEMA +_FD_SEND_KEYS = (CONF_FD, CONF_BIT_RATE_SWITCH, CONF_PAD_DATA) + + +def _flag(name: str, value: bool) -> str: + return name if value else f"!{name}" def validate_id(config: ConfigType) -> ConfigType: @@ -49,11 +68,8 @@ def validate_raw_data(value: Any) -> bytes | list: canbus_ns = cg.esphome_ns.namespace("canbus") CanbusComponent = canbus_ns.class_("CanbusComponent", cg.Component) -CanbusTrigger = canbus_ns.class_( - "CanbusTrigger", - automation.Trigger.template(cg.std_vector.template(cg.uint8), cg.uint32), - cg.Component, -) +CanFrame = canbus_ns.struct("CanFrame") +CanPayload = canbus_ns.class_("CanPayload") CanSpeed = canbus_ns.enum("CAN_SPEED") CAN_SPEEDS = CanSpeed.options( @@ -81,6 +97,21 @@ CAN_SPEEDS = CanSpeed.options( prefix="CAN_", ) +# Data phase rates for a CAN FD platform's own schema +CAN_DATA_SPEEDS = { + **CAN_SPEEDS, + **CanSpeed.options( + "2000KBPS", + "3000KBPS", + "4000KBPS", + "5000KBPS", + "6000KBPS", + "7000KBPS", + "8000KBPS", + prefix="CAN_", + ), +} + def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) @@ -93,7 +124,7 @@ def get_rate(value: str) -> int: CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), - cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( CAN_SPEEDS, upper=True ), @@ -103,10 +134,9 @@ CANBUS_SCHEMA = cv.Schema( ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), - cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_CAN_ID_MASK, default=0x1FFFFFFF): cv.int_range( - min=0, max=0x1FFFFFFF + cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), + cv.Optional(CONF_CAN_ID_MASK, default=CAN_ID_MASK_ALL): cv.int_range( + min=0, max=CAN_ID_MASK_ALL ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST): cv.boolean, @@ -118,39 +148,67 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) +# A platform whose controller supports CAN FD extends this instead of CANBUS_SCHEMA. Its buses then accept the +# CAN FD options of canbus.send, and register_canbus() turns on USE_CANBUS_FD, which makes every CanFrame +# 64 bytes long. +CANBUS_FD_SCHEMA = CANBUS_SCHEMA.extend( + { + cv.Optional(CONF_DEFAULT_FD, default=False): cv.boolean, + cv.Optional(CONF_DEFAULT_BIT_RATE_SWITCH, default=False): cv.boolean, + cv.Optional(CONF_DEFAULT_PAD_DATA, default=False): cv.boolean, + } +) + + +def _is_fd_bus(config: ConfigType) -> bool: + return CONF_DEFAULT_FD in config + async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id(config[CONF_CAN_ID])) if config[CONF_USE_EXTENDED_ID]: cg.add(var.set_use_extended_id(True)) + if _is_fd_bus(config): + cg.add_define("USE_CANBUS_FD") + cg.add(var.set_supports_fd(True)) + for key, setter in ( + (CONF_DEFAULT_FD, var.set_default_fd), + (CONF_DEFAULT_BIT_RATE_SWITCH, var.set_default_bit_rate_switch), + (CONF_DEFAULT_PAD_DATA, var.set_default_pad_data), + ): + if config.get(key): + cg.add(setter(True)) if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): - can_id = conf[CONF_CAN_ID] - can_id_mask = conf[CONF_CAN_ID_MASK] - ext_id = conf[CONF_USE_EXTENDED_ID] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], var, can_id, can_id_mask, ext_id - ) - if CONF_REMOTE_TRANSMISSION_REQUEST in conf: - cg.add( - trigger.set_remote_transmission_request( - conf[CONF_REMOTE_TRANSMISSION_REQUEST] - ) - ) - await cg.register_component(trigger, conf) - await automation.build_automation( - trigger, + can_id = f"0x{conf[CONF_CAN_ID]:X}" + if (mask := conf[CONF_CAN_ID_MASK]) != CAN_ID_MASK_ALL: + checks = [f"(frame.can_id & 0x{mask:X}) == {can_id}"] + else: + checks = [f"frame.can_id == {can_id}"] + checks.append(_flag("frame.use_extended_id", conf[CONF_USE_EXTENDED_ID])) + if (rtr := conf.get(CONF_REMOTE_TRANSMISSION_REQUEST)) is not None: + checks.append(_flag("frame.remote_transmission_request", rtr)) + await automation.build_callback_automation( + var, + "add_frame_callback", [ - (cg.std_vector.template(cg.uint8), "x"), + (CanPayload, "x"), (cg.uint32, "can_id"), (cg.bool_, "remote_transmission_request"), ], conf, + params=[(CanFrame.operator("const").operator("ref"), "frame")], + forward=[ + "frame.payload()", + "frame.can_id", + "frame.remote_transmission_request", + ], + when=" && ".join(checks), ) @@ -170,16 +228,66 @@ async def register_canbus( await setup_canbus_core_(var, config) +def validate_send(action: ConfigType, bus: ConfigType) -> None: + """Reject a canbus.send that its bus cannot send, as far as the values are known. + + Lambda values are checked when the action runs. + """ + data = action[CONF_DATA] + length = None if cg.is_template(data) else len(data) + if not _is_fd_bus(bus): + for key in _FD_SEND_KEYS: + if key in action: + raise cv.Invalid( + f"{key} needs a canbus platform that supports CAN FD", path=[key] + ) + fd = False + else: + fd = action.get(CONF_FD, bus[CONF_DEFAULT_FD]) + if fd is False and action.get(CONF_BIT_RATE_SWITCH) is True: + raise cv.Invalid("bit_rate_switch requires fd: true") + if fd is True and action[CONF_REMOTE_TRANSMISSION_REQUEST]: + raise cv.Invalid("remote_transmission_request cannot be used with fd: true") + if length is None: + return + if fd is False and length > CAN_MAX_DATA_LENGTH: + raise cv.Invalid("Classic CAN data must be at most 8 bytes", path=[CONF_DATA]) + if ( + fd is True + and length not in CAN_FD_DATA_LENGTHS + and action.get(CONF_PAD_DATA, bus[CONF_DEFAULT_PAD_DATA]) is False + ): + raise cv.Invalid("CAN FD data length requires pad_data: true", path=[CONF_DATA]) + + +def final_validate(config: list[ConfigType]) -> None: + full_config = fv.full_config.get() + for bus_id, path in iter_ids(full_config): + if len(path) < 2 or path[-2:] != ["canbus.send", CONF_CANBUS_ID]: + continue + action_path = path[:-1] + action = full_config.get_config_for_path(action_path) + bus = fv.get_declaring_config(bus_id, full_config) + with cv.prepend_path([cv.ROOT_CONFIG_PATH, *action_path]): + validate_send(action, bus) + + +FINAL_VALIDATE_SCHEMA = final_validate + + # Actions CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), - cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, - # One classic CAN frame + cv.Optional(CONF_FD): cv.templatable(cv.boolean), + cv.Optional(CONF_BIT_RATE_SWITCH): cv.templatable(cv.boolean), + cv.Optional(CONF_PAD_DATA): cv.templatable(cv.boolean), + # One CAN FD frame; final_validate holds a classic bus to 8 bytes cv.Required(CONF_DATA): cv.templatable( - cv.All(validate_raw_data, cv.Length(max=8)) + cv.All(validate_raw_data, cv.Length(max=CAN_FD_DATA_LENGTHS[-1])) ), }, validate_id, @@ -209,6 +317,14 @@ async def canbus_action_to_code( cg.add( var.set_remote_transmission_request(config[CONF_REMOTE_TRANSMISSION_REQUEST]) ) + # Only set on a CAN FD bus, which final_validate checks + for key, setter in ( + (CONF_FD, var.set_fd), + (CONF_BIT_RATE_SWITCH, var.set_bit_rate_switch), + (CONF_PAD_DATA, var.set_pad_data), + ): + if (value := config.get(key)) is not None: + cg.add(setter(await cg.templatable(value, args, cg.bool_))) await automation.templatable_bytes( config[CONF_DATA], @@ -216,5 +332,6 @@ async def canbus_action_to_code( var.set_data_template, var.set_data_static, "canbus_data", + return_type=CanPayload, ) return var diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index dfe3ab9fd31..b60a4134476 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -22,49 +22,112 @@ void Canbus::dump_config() { } } +canbus::Error Canbus::send_data(const CanFrame &frame) { + if (frame.can_id > (frame.use_extended_id ? CAN_EXTENDED_ID_MAX : CAN_STANDARD_ID_MAX)) { + ESP_LOGE(TAG, "CAN ID 0x%08" PRIx32 " exceeds frame identifier width", frame.can_id); + return ERROR_FAILTX; + } +#ifdef USE_CANBUS_FD + if (frame.fd && !this->supports_fd_) { + ESP_LOGE(TAG, "CAN FD frame on a bus without CAN FD"); + return ERROR_FAILTX; + } + if (frame.fd ? (frame.remote_transmission_request || can_dlc_to_len(frame.can_data_length_code) == CAN_INVALID_DLC) + : frame.bit_rate_switch) { + ESP_LOGE(TAG, "Invalid CAN frame: fd=%d rtr=%d brs=%d dlc=%" PRIu8, frame.fd, frame.remote_transmission_request, + frame.bit_rate_switch, frame.can_data_length_code); + return ERROR_FAILTX; + } +#endif + return this->send_frame_(frame); +} + +#ifdef USE_CANBUS_FD +canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, + const uint8_t *data, size_t len, bool fd, bool bit_rate_switch, bool pad_data) { +#else canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, size_t len) { - struct CanFrame can_message; - +#endif + CanFrame frame; + frame.use_extended_id = use_extended_id; + frame.remote_transmission_request = remote_transmission_request; + frame.can_id = can_id; +#ifdef USE_CANBUS_FD + frame.fd = fd; + frame.bit_rate_switch = bit_rate_switch; + frame.error_state_indicator = false; + if (fd) { + frame.can_data_length_code = can_len_to_dlc(len, pad_data); + if (frame.can_data_length_code == CAN_INVALID_DLC) { + ESP_LOGE(TAG, "CAN FD cannot carry %zu bytes without pad_data", len); + return ERROR_FAILTX; + } + if (!remote_transmission_request && len != 0) { + std::memcpy(frame.data, data, len); + std::memset(frame.data + len, 0, can_dlc_to_len(frame.can_data_length_code) - len); + } + return this->send_data(frame); + } +#endif + if (len > CAN_MAX_DATA_LENGTH) { + ESP_LOGW(TAG, "Classic CAN carries 8 bytes, %zu given, sent 8", len); + } // Clamp before narrowing, so a lambda payload of 256 bytes or more cannot wrap to a short length - const uint8_t size = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); - if (use_extended_id) { - ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), - size); + frame.can_data_length_code = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); + // A remote request carries no data, so its bytes are zeroed rather than left from the stack. An empty vector may + // hand over a null pointer, which memcpy must not get even for 0 bytes. + if (remote_transmission_request) { + std::memset(frame.data, 0, frame.can_data_length_code); + } else if (frame.can_data_length_code != 0) { + std::memcpy(frame.data, data, frame.can_data_length_code); + } +#ifdef USE_CANBUS_FD + // The caller's flags can still contradict each other + return this->send_data(frame); +#else + return this->send_frame_(frame); +#endif +} + +bool Canbus::lambda_payload_fits_(const CanPayload &payload, [[maybe_unused]] bool fd) { + if (!payload.truncated()) + return true; +#ifdef USE_CANBUS_FD + if (fd) { + ESP_LOGE(TAG, "CAN FD carries at most %u bytes, frame not sent", CAN_FRAME_DATA_LENGTH); + return false; + } + // The payload still holds 64 bytes, so send_data() warns as it cuts a classic frame to 8 +#else + ESP_LOGW(TAG, "Classic CAN carries 8 bytes, the rest is cut"); +#endif + return true; +} + +canbus::Error Canbus::send_frame_(const CanFrame &frame) { + const uint8_t size = frame.payload_length(); + if (frame.use_extended_id) { + ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", frame.can_id, + TRUEFALSE(frame.remote_transmission_request), size); } else { - ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), - size); + ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", frame.can_id, + TRUEFALSE(frame.remote_transmission_request), size); } - can_message.can_data_length_code = size; - can_message.can_id = can_id; - can_message.use_extended_id = use_extended_id; - can_message.remote_transmission_request = remote_transmission_request; + ESP_LOGV_HEX(TAG, frame.data, size, CAN_FRAME_DATA_LENGTH, " data=%s"); - for (int i = 0; i < size; i++) { - can_message.data[i] = data[i]; - ESP_LOGVV(TAG, " data[%d]=%02x", i, can_message.data[i]); - } - - canbus::Error error = this->send_message(&can_message); + // Drivers take a mutable pointer but do not change the frame + canbus::Error error = this->send_message(const_cast(&frame)); if (error != canbus::ERROR_OK) { - if (use_extended_id) { - ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed with error %d!", can_id, error); + if (frame.use_extended_id) { + ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed with error %d!", frame.can_id, error); } else { - ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed with error %d!", can_id, error); + ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed with error %d!", frame.can_id, error); } } return error; } -void Canbus::add_trigger(CanbusTrigger *trigger) { - if (trigger->use_extended_id_) { - ESP_LOGVV(TAG, "add trigger for extended canid=0x%08" PRIx32, trigger->can_id_); - } else { - ESP_LOGVV(TAG, "add trigger for std canid=0x%03" PRIx32, trigger->can_id_); - } - this->triggers_.push_back(trigger); -}; - void Canbus::loop() { uint32_t now = App.get_loop_component_start_time(); if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { @@ -75,38 +138,27 @@ void Canbus::loop() { struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { +#ifdef USE_CANBUS_FD + // Classic drivers leave the CAN FD flags alone + can_message.fd = false; + can_message.bit_rate_switch = false; + can_message.error_state_indicator = false; +#endif if (this->read_message(&can_message) != canbus::ERROR_OK) { break; } + const uint8_t size = can_message.payload_length(); if (can_message.use_extended_id) { ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, - can_message.can_id, can_message.can_data_length_code); + can_message.can_id, size); } else { ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, - can_message.can_id, can_message.can_data_length_code); + can_message.can_id, size); } - std::vector data; - - // show data received - for (int i = 0; i < std::min(can_message.can_data_length_code, CAN_MAX_DATA_LENGTH); i++) { - ESP_LOGV(TAG, " can_message.data[%d]=%02x", i, can_message.data[i]); - data.push_back(can_message.data[i]); - } - - this->callback_manager_(can_message.can_id, can_message.use_extended_id, can_message.remote_transmission_request, - data); - - // fire all triggers - for (auto *trigger : this->triggers_) { - if ((trigger->can_id_ == (can_message.can_id & trigger->can_id_mask_)) && - (trigger->use_extended_id_ == can_message.use_extended_id) && - (!trigger->remote_transmission_request_.has_value() || - trigger->remote_transmission_request_.value() == can_message.remote_transmission_request)) { - trigger->trigger(data, can_message.can_id, can_message.remote_transmission_request); - } - } + ESP_LOGV_HEX(TAG, can_message.data, size, CAN_FRAME_DATA_LENGTH, " data=%s"); + this->frame_callbacks_.call(can_message); } if (message_counter == this->max_frames_per_loop_) { diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 6c12768273d..f46d92bff01 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -2,9 +2,16 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/optional.h" +#include #include +#include +#include +#include +#include #include namespace esphome::canbus { @@ -39,28 +46,128 @@ enum CanSpeed : uint8_t { CAN_250KBPS, CAN_500KBPS, CAN_800KBPS, - CAN_1000KBPS + CAN_1000KBPS, +#ifdef USE_CANBUS_FD + // Data phase rates of a CAN FD controller + CAN_2000KBPS, + CAN_3000KBPS, + CAN_4000KBPS, + CAN_5000KBPS, + CAN_6000KBPS, + CAN_7000KBPS, + CAN_8000KBPS, +#endif }; -class CanbusTrigger; template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ -static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static constexpr uint8_t CAN_MAX_DATA_LENGTH = 8; +#ifdef USE_CANBUS_FD +static constexpr uint8_t CAN_FD_MAX_DATA_LENGTH = 64; +/// Payload bytes a CanFrame holds: one CAN FD frame when a platform that supports it is configured +static constexpr uint8_t CAN_FRAME_DATA_LENGTH = CAN_FD_MAX_DATA_LENGTH; +#else +/// Payload bytes a CanFrame holds: one classic frame +static constexpr uint8_t CAN_FRAME_DATA_LENGTH = CAN_MAX_DATA_LENGTH; +#endif -static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; +static constexpr uint32_t CAN_STANDARD_ID_MAX = 0x7FF; +static constexpr uint32_t CAN_EXTENDED_ID_MAX = 0x1FFFFFFF; -/* -Can Frame describes a normative CAN Frame -The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used -So currently the flag is passed to and from the hardware but currently ignored to the user application. -*/ +static constexpr uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + +#ifdef USE_CANBUS_FD +/// Returned by the length conversions for a value that cannot be represented +static constexpr uint8_t CAN_INVALID_DLC = UINT8_MAX; + +/// Payload length of each CAN FD DLC; a classic frame carries min(DLC, 8) bytes +static constexpr uint8_t CAN_FD_DLC_COUNT = 16; +static constexpr uint8_t CAN_FD_DLC_LENGTHS[CAN_FD_DLC_COUNT] PROGMEM = {0, 1, 2, 3, 4, 5, 6, 7, + 8, 12, 16, 20, 24, 32, 48, 64}; + +/// CAN_FD_DLC_LENGTHS[dlc] for dlc below CAN_FD_DLC_COUNT; read from flash at run time +constexpr uint8_t can_fd_dlc_length(uint8_t dlc) { + return std::is_constant_evaluated() ? CAN_FD_DLC_LENGTHS[dlc] : progmem_read_byte(&CAN_FD_DLC_LENGTHS[dlc]); +} + +/// Payload length of a CAN FD DLC, or CAN_INVALID_DLC above 15 +constexpr uint8_t can_dlc_to_len(uint8_t dlc) { + return dlc < CAN_FD_DLC_COUNT ? can_fd_dlc_length(dlc) : CAN_INVALID_DLC; +} + +/// CAN FD DLC for a payload length. With pad, the next longer length is used and the caller zero fills +/// the extra bytes; otherwise a length CAN FD cannot carry returns CAN_INVALID_DLC. +constexpr uint8_t can_len_to_dlc(size_t len, bool pad) { + for (uint8_t dlc = 0; dlc < CAN_FD_DLC_COUNT; dlc++) { + const uint8_t length = can_fd_dlc_length(dlc); + if (length >= len) + return (length == len || pad) ? dlc : CAN_INVALID_DLC; + } + return CAN_INVALID_DLC; +} +#else +/// Payload length of a classic DLC; 9 to 15 still carry 8 bytes +constexpr uint8_t can_dlc_to_len(uint8_t dlc) { return std::min(dlc, CAN_MAX_DATA_LENGTH); } +#endif + +/// Frame payload without heap; converts to and from std::vector for older lambdas. +class CanPayload : public StaticVector { + public: + using StaticVector::StaticVector; + // Not = default, so value-init does not zero the buffer + CanPayload() {} + // Every constructor that can be given more than one frame records that it cut the rest + template CanPayload(InputIt first, InputIt last) { + for (; first != last; ++first) { + if (this->size() == CAN_FRAME_DATA_LENGTH) { + this->truncated_ = true; + break; + } + this->push_back(*first); + } + } + CanPayload(std::initializer_list data) : CanPayload(data.begin(), data.end()) {} + CanPayload(const std::vector &data) // NOLINT(google-explicit-constructor) + : CanPayload(data.begin(), data.end()) {} + operator std::vector() const { return {this->begin(), this->end()}; } // NOLINT(google-explicit-constructor) + /// Whether it was built from more bytes than one frame can carry + bool truncated() const { return this->truncated_; } + + protected: + bool truncated_{false}; +}; + +/// One CAN frame as passed between the bus and its driver. The CAN FD flags come after data, so code that +/// brace-initializes a classic frame keeps working. struct CanFrame { bool use_extended_id = false; bool remote_transmission_request = false; - uint32_t can_id; /* 29 or 11 bit CAN_ID */ - uint8_t can_data_length_code; /* frame payload length in byte (0 .. CAN_MAX_DATA_LENGTH) */ - uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); + uint32_t can_id; /* 29 or 11 bit CAN_ID; drivers clear bits 29-31 (on_frame relies on it) */ + uint8_t can_data_length_code; /* DLC as on the wire, not a byte count (see payload_length()) */ + uint8_t data[CAN_FRAME_DATA_LENGTH] __attribute__((aligned(8))); +#ifdef USE_CANBUS_FD + bool fd = false; /* CAN FD frame; it cannot be a remote transmission request */ + bool bit_rate_switch = false; /* CAN FD only: data phase sent at the data bit rate */ + bool error_state_indicator = false; /* CAN FD only: received transmitter was error passive */ +#endif + + /// Payload bytes for the DLC: at most 8 for a classic frame, 0 for an invalid CAN FD DLC + uint8_t payload_length() const { +#ifdef USE_CANBUS_FD + if (this->fd) { + const uint8_t len = can_dlc_to_len(this->can_data_length_code); + return len == CAN_INVALID_DLC ? 0 : len; + } +#endif + return std::min(this->can_data_length_code, CAN_MAX_DATA_LENGTH); + } + CanPayload payload() const { + CanPayload out; + std::memcpy(out.data(), this->data, CAN_FRAME_DATA_LENGTH); + out.resize(this->payload_length()); + return out; + } }; enum CanEventFlags : uint8_t { @@ -91,8 +198,18 @@ class Canbus : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; + /// Validates the frame and passes it to the driver. + canbus::Error send_data(const CanFrame &frame); +#ifdef USE_CANBUS_FD + /// Sends up to 8 bytes as a classic frame, longer data is cut. An fd frame carries up to 64 bytes; pad_data + /// zero fills a length CAN FD cannot carry up to the next one. + canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, + size_t len, bool fd = false, bool bit_rate_switch = false, bool pad_data = false); +#else + /// Sends up to 8 bytes as a classic frame, longer data is cut. canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, size_t len); +#endif canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const std::vector &data) { return this->send_data(can_id, use_extended_id, remote_transmission_request, data.data(), data.size()); @@ -105,9 +222,28 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } +#ifdef USE_CANBUS_FD + // Defaults for canbus.send actions only, not for send_data() + void set_default_fd(bool default_fd) { this->default_fd_ = default_fd; } + void set_default_bit_rate_switch(bool default_bit_rate_switch) { + this->default_bit_rate_switch_ = default_bit_rate_switch; + } + void set_default_pad_data(bool default_pad_data) { this->default_pad_data_ = default_pad_data; } + void set_supports_fd(bool supports_fd) { this->supports_fd_ = supports_fd; } +#endif + /// Whether this bus sends and receives CAN FD frames; a classic bus can share a build with a CAN FD one + bool supports_fd() const { +#ifdef USE_CANBUS_FD + return this->supports_fd_; +#else + return false; +#endif + } virtual CanStatus get_status() { return {}; } - void add_trigger(CanbusTrigger *trigger); + /// The frame is only valid during the call. + template void add_frame_callback(F &&callback) { this->frame_callbacks_.add(std::forward(callback)); } + /** * Add a callback to be called when a CAN message is received. All received messages * are passed to the callback without filtering. @@ -118,19 +254,30 @@ class Canbus : public Component { * - rtr If this is a remote transmission request * - data The message data */ - template void add_callback(F &&callback) { this->callback_manager_.add(std::forward(callback)); } + template + ESPDEPRECATED("Use add_frame_callback(), which does not copy the payload. Removed in 2027.5.0.", "2026.11.0") + void add_callback(F &&callback) { + this->frame_callbacks_.add([cb = std::forward(callback)](const CanFrame &frame) mutable { + cb(frame.can_id, frame.use_extended_id, frame.remote_transmission_request, + std::vector(frame.data, frame.data + frame.payload_length())); + }); + } protected: template friend class CanbusSendAction; - std::vector triggers_{}; uint32_t can_id_; uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; +#ifdef USE_CANBUS_FD + bool default_fd_{false}; + bool default_bit_rate_switch_{false}; + bool default_pad_data_{false}; + bool supports_fd_{false}; +#endif uint8_t events_to_log_{0}; bool bus_off_{false}; - CallbackManager &data)> - callback_manager_{}; + CallbackManager frame_callbacks_{}; // interval in which to check for new can bus events (bus-off, passive, active) static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; @@ -150,6 +297,10 @@ class Canbus : public Component { #endif void log_events_(CanEventFlags events, uint32_t now); + /// Logs and sends a frame that is already valid. + canbus::Error send_frame_(const CanFrame &frame); + /// For a canbus.send lambda: a CAN FD frame that did not fit is refused, a classic one is cut to 8 bytes. + bool lambda_payload_fits_(const CanPayload &payload, [[maybe_unused]] bool fd); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; @@ -157,7 +308,12 @@ class Canbus : public Component { }; template class CanbusSendAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) + TEMPLATABLE_BUFFER(data, CanPayload) +#ifdef USE_CANBUS_FD + TEMPLATABLE_VALUE(bool, fd) + TEMPLATABLE_VALUE(bool, bit_rate_switch) + TEMPLATABLE_VALUE(bool, pad_data) +#endif void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } @@ -171,11 +327,30 @@ template class CanbusSendAction final : public Action, pu auto can_id = this->can_id_.has_value() ? *this->can_id_ : this->parent_->can_id_; auto use_extended_id = this->use_extended_id_.has_value() ? *this->use_extended_id_ : this->parent_->use_extended_id_; - this->data_.template visit( - [&](const uint8_t *data, size_t len) { - this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); - }, - x...); +#ifdef USE_CANBUS_FD + const bool fd = this->fd_.value_or(x..., this->parent_->default_fd_); + const bool bit_rate_switch = this->bit_rate_switch_.value_or(x..., fd && this->parent_->default_bit_rate_switch_); + const bool pad_data = this->pad_data_.value_or(x..., this->parent_->default_pad_data_); +#endif +#ifndef USE_CANBUS_FD + const bool fd = false; +#endif + auto send = [&](const uint8_t *data, size_t len) { +#ifdef USE_CANBUS_FD + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len, fd, + bit_rate_switch, pad_data); +#else + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); +#endif + }; + if (this->data_.is_static()) { + this->data_.template visit(send, x...); + return; + } + // The conversion from a lambda's vector cuts it to one frame, so the length it had is checked here + const CanPayload payload = this->data_.value(x...); + if (this->parent_->lambda_payload_fits_(payload, fd)) + send(payload.data(), payload.size()); } protected: @@ -184,26 +359,4 @@ template class CanbusSendAction final : public Action, pu bool remote_transmission_request_{false}; }; -class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { - friend class Canbus; - - public: - explicit CanbusTrigger(Canbus *parent, const std::uint32_t can_id, const std::uint32_t can_id_mask, - const bool use_extended_id) - : parent_(parent), can_id_(can_id), can_id_mask_(can_id_mask), use_extended_id_(use_extended_id) {}; - - void set_remote_transmission_request(bool remote_transmission_request) { - this->remote_transmission_request_ = remote_transmission_request; - } - - void setup() override { this->parent_->add_trigger(this); } - - protected: - Canbus *parent_; - uint32_t can_id_; - uint32_t can_id_mask_; - bool use_extended_id_; - optional remote_transmission_request_{}; -}; - } // namespace esphome::canbus diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 2a47a4e2f6b..303c0e14a0b 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -22,23 +22,18 @@ void DAC7678Output::setup() { ESP_LOGV(TAG, "Resetting device"); // Reset device - if (!this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000)) { - this->mark_failed(LOG_STR("Reset failed")); + if (!this->setup_check(this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000), LOG_STR("Reset failed"))) return; - } else { - ESP_LOGV(TAG, "Reset succeeded"); - } + ESP_LOGV(TAG, "Reset succeeded"); delayMicroseconds(1000); // Set internal reference if (this->internal_reference_) { - if (!this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4)) { - this->mark_failed(LOG_STR("Set internal reference failed")); + if (!this->setup_check(this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4), + LOG_STR("Set internal reference failed"))) return; - } else { - ESP_LOGV(TAG, "Internal reference enabled"); - } + ESP_LOGV(TAG, "Internal reference enabled"); } } diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index eecde9343a0..17df598e4a3 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -64,10 +64,8 @@ void ENS160Component::setup() { uint16_t part_id; if (!this->setup_check(this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2))) return; - if (part_id != ENS160_PART_ID) { - this->mark_failed(LOG_STR("Sensor reported an invalid ID. Is this a ENS160?")); + if (!this->setup_check(part_id == ENS160_PART_ID, LOG_STR("Sensor reported an invalid ID. Is this a ENS160?"))) return; - } // set mode to reset if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET))) @@ -80,10 +78,8 @@ void ENS160Component::setup() { return; this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); - if (this->validity_flag_ == INVALID_OUTPUT) { - this->mark_failed(LOG_STR("Invalid Device Status - No valid output")); + if (!this->setup_check(this->validity_flag_ != INVALID_OUTPUT, LOG_STR("Invalid Device Status - No valid output"))) return; - } // set mode to idle if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE))) @@ -110,20 +106,15 @@ void ENS160Component::setup() { this->firmware_ver_build_ = version_data[2]; // set mode to standard - if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD))) return; - } // read opmode and check standard mode is achieved before finishing Setup uint8_t op_mode; - if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->read_byte(ENS160_REG_OPMODE, &op_mode))) return; - } - if (!this->setup_check(op_mode == ENS160_OPMODE_STD, LOG_STR("Device failed to achieve Standard Operating Mode"))) - return; + this->setup_check(op_mode == ENS160_OPMODE_STD, LOG_STR("Device failed to achieve Standard Operating Mode")); } void ENS160Component::update() { diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 5f00fa59e90..e5bca4c0178 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -19,10 +19,8 @@ const LogString *EPaperBase::epaper_state_to_string_() { } void EPaperBase::setup() { - if (!this->init_buffer_(this->buffer_length_)) { - this->mark_failed(LOG_STR("Failed to initialise buffer")); + if (!this->setup_check(this->init_buffer_(this->buffer_length_), LOG_STR("Failed to initialise buffer"))) return; - } this->setup_pins_(); this->spi_setup(); } diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 404a14ab102..9bb2ee37b10 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -4,6 +4,8 @@ Supported models: - waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2) - seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same 7.5" 800x480 panel on an integrated ESP32-S3 board +- goodisplay-gdey0583t81-5.83: Good Display GDEY0583T81 5.83" mono display, + 648x480 pixels, also sold by Seeed as 0583BN-T81-D2 Panel configuration and power-on (0x04) are both sent during the INITIALISE state; the state machine's built-in busy wait then covers the power-on delay @@ -75,6 +77,17 @@ waveshare_7_5_v2 = uc8179.extend( height=480, ) +# Good Display GDEY0583T81, 5.83" mono 648x480, UC8179; also sold by Seeed as 0583BN-T81-D2. +# Replaces the waveshare_epaper model gdey0583t81. Busy is low while busy, so set inverted: true. +goodisplay_gdey0583t81 = uc8179.extend( + "goodisplay-gdey0583t81-5.83", + width=648, + height=480, +) + +# Name printed on the panel's label as sold by Seeed +goodisplay_gdey0583t81.extend("0583bn-t81-d2") + # Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the # Waveshare 7.5" V2, driven by an integrated ESP32-S3 board waveshare_7_5_v2.extend( diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 739bfab91cb..50be9c56be8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -236,15 +236,15 @@ COMPILER_OPTIMIZATIONS = { # # Note: excluding a component only removes it from the initial build set. # ESP-IDF's requirement expansion adds an excluded component back when any -# component still in the build REQUIRES it (e.g. espressif/mdns pulls -# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# component still in the build REQUIRES it (e.g. esp_http_client pulls +# "tcp_transport" back in), so # exclusions here are safe for such components and simply become no-ops in # builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "app_trace", # CPU trace/SystemView support - unused by ESPHome "bt", # Bluetooth stack - re-included by request_bluetooth(); its REQUIRES pulls the WiFi stack back "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing - "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured + "console", # Console REPL - unused by ESPHome; espressif/mdns needs it only for its CLI, which mdns disables "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component @@ -3003,6 +3003,7 @@ async def _finalize_arduino_aware_flags(): cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 99380139240..b0c22535913 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -5,6 +5,7 @@ #include "crash_handler.h" #include "esphome/core/build_info_data.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -500,6 +501,84 @@ void crash_handler_log() { } // namespace esphome::esp32 +namespace esphome { + +static_assert(MAX_BACKTRACE <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +// The record itself stays valid for log replay this boot +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Filtered as in log_backtrace() +static uint8_t copy_backtrace(const uint32_t *addrs, uint8_t count, [[maybe_unused]] uint8_t reg_frame_count, + [[maybe_unused]] bool filter, uint32_t *out) { + uint8_t out_count = 0; + for (uint8_t i = 0; i < count; i++) { +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (filter && i >= reg_frame_count && !is_return_addr(addrs[i])) + continue; +#endif + out[out_count++] = addrs[i]; + } + return out_count; +} + +static CrashReason get_crash_reason() { + switch (s_raw_crash_data.exception) { + case PANIC_EXCEPTION_ABORT: + return CrashReason::CRASH_REASON_ABORT; + case PANIC_EXCEPTION_TWDT: + return CrashReason::CRASH_REASON_TASK_WATCHDOG; + case PANIC_EXCEPTION_IWDT: + return CrashReason::CRASH_REASON_INTERRUPT_WATCHDOG; + default: + return CrashReason::CRASH_REASON_EXCEPTION; + } +} + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !esp32::crash_handler_has_data()) + return false; + out = {}; + out.flags = CRASH_REPORT_FLAG_PRESENT; + const bool build_matches = s_raw_crash_data.build_time == esp32::s_current_build_time; + if (build_matches) + out.flags |= CRASH_REPORT_FLAG_BUILD_MATCHES; + out.reason = get_crash_reason(); + out.crashed_core = s_raw_crash_data.crashed_core; + out.pc = s_raw_crash_data.pc; + out.build_time = s_raw_crash_data.build_time; + // A pseudo cause is an IDF panic reason + if (s_raw_crash_data.exception != PANIC_EXCEPTION_ABORT && s_raw_crash_data.exception != PANIC_EXCEPTION_TWDT && + !s_raw_crash_data.pseudo_excause && esp32::cause_slot_was_written()) { + out.flags |= CRASH_REPORT_FLAG_CAUSE; + out.cause = s_raw_crash_data.cause; + } + if (esp32::has_fault_addr()) { + out.flags |= CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.fault_address = s_raw_crash_data.fault_addr; + } + auto &crashed = out.cores[out.core_count++]; + crashed.core = s_raw_crash_data.crashed_core; + crashed.count = copy_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count, build_matches, crashed.addresses.data()); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + auto &other = out.cores[out.core_count++]; + other.core = 1 - s_raw_crash_data.crashed_core; + other.count = copy_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count, build_matches, other.addresses.data()); + } +#endif + return true; +} + +void crash_report_clear() { + s_report_read = true; + esp32::crash_handler_clear(); +} + +} // namespace esphome + // --- Panic handler wrapper --- // Intercepts esp_panic_handler() via --wrap linker flag to capture crash data // into NOINIT memory before the normal panic handler runs. diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index a248b901204..1597074c404 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -55,10 +55,8 @@ void CameraWebServer::setup() { config.backlog_conn = 2; config.lru_purge_enable = true; - if (httpd_start(&this->httpd_, &config) != ESP_OK) { - this->mark_failed(LOG_STR("Failed to start the web server")); + if (!this->setup_check(httpd_start(&this->httpd_, &config) == ESP_OK, LOG_STR("Failed to start the web server"))) return; - } httpd_uri_t uri = { .uri = "/", diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ac0bce3c603..213bb3086d8 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -69,10 +69,9 @@ void ESP32RMTLEDStripLightOutput::setup() { size_t buffer_size = this->get_buffer_size_(); RAMAllocator allocator(this->use_psram_ ? 0 : RAMAllocator::ALLOC_INTERNAL); - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) // copy of the led buffer @@ -104,10 +103,8 @@ void ESP32RMTLEDStripLightOutput::setup() { channel.flags.invert_out = this->invert_out_; channel.flags.with_dma = this->use_dma_; channel.intr_priority = 0; - if (rmt_new_tx_channel(&channel, &this->channel_) != ESP_OK) { - this->mark_failed(LOG_STR("Channel creation failed")); + if (!this->setup_check(rmt_new_tx_channel(&channel, &this->channel_) == ESP_OK, LOG_STR("Channel creation failed"))) return; - } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) rmt_simple_encoder_config_t encoder; @@ -115,23 +112,18 @@ void ESP32RMTLEDStripLightOutput::setup() { encoder.callback = encoder_callback; encoder.arg = &this->params_; encoder.min_chunk_size = RMT_SYMBOLS_PER_BYTE; - if (rmt_new_simple_encoder(&encoder, &this->encoder_) != ESP_OK) { - this->mark_failed(LOG_STR("Encoder creation failed")); + if (!this->setup_check(rmt_new_simple_encoder(&encoder, &this->encoder_) == ESP_OK, + LOG_STR("Encoder creation failed"))) return; - } #else rmt_copy_encoder_config_t encoder; memset(&encoder, 0, sizeof(encoder)); - if (rmt_new_copy_encoder(&encoder, &this->encoder_) != ESP_OK) { - this->mark_failed(LOG_STR("Encoder creation failed")); + if (!this->setup_check(rmt_new_copy_encoder(&encoder, &this->encoder_) == ESP_OK, LOG_STR("Encoder creation failed"))) return; - } #endif - if (rmt_enable(this->channel_) != ESP_OK) { - this->mark_failed(LOG_STR("Enabling channel failed")); + if (!this->setup_check(rmt_enable(this->channel_) == ESP_OK, LOG_STR("Enabling channel failed"))) return; - } this->accessor_ = &this->internal_accessor_; } diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index c980b2d8919..ac245c957f4 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -424,6 +424,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 8513d213155..ceffa53847e 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -5,6 +5,7 @@ #include "crash_handler.h" #include "rtc_layout.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -123,6 +124,21 @@ static const LogString *get_reset_reason(uint32_t reason) { return LOG_STR("Unknown"); } +// GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific +// ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match +// the Arduino core's postmortem handler behavior. +static uint32_t get_exccause() { + static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; + static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; + if (resetInfo.exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && + (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { + return EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; + } + return resetInfo.exccause; +} + // Read backtrace from RTC user memory into caller-provided buffer. // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { @@ -172,18 +188,7 @@ void crash_handler_log() { // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific - // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match - // the Arduino core's postmortem handler behavior. - static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; - static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; - static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; - static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; - uint32_t exccause = resetInfo.exccause; - if (exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && - (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { - exccause = EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; - } + uint32_t exccause = get_exccause(); const LogString *cause = get_exception_cause(exccause); if (cause != nullptr) { ESP_LOGE(TAG, " Reason: %s - %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), @@ -202,6 +207,41 @@ void crash_handler_log() { } // namespace esphome::esp8266 +namespace esphome { + +static_assert(MAX_BACKTRACE <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !esp8266::crash_handler_has_data()) + return false; + out = {}; + // Never survives into another build + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + if (resetInfo.reason == REASON_WDT_RST) { + // Nothing else is recorded for a hardware WDT reset + out.reason = CrashReason::CRASH_REASON_HARDWARE_WATCHDOG; + return true; + } + out.pc = resetInfo.epc1; + if (resetInfo.reason == REASON_EXCEPTION_RST) { + out.reason = CrashReason::CRASH_REASON_EXCEPTION; + out.flags |= CRASH_REPORT_FLAG_CAUSE | CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.cause = esp8266::get_exccause(); + out.fault_address = resetInfo.excvaddr; + } else { + out.reason = CrashReason::CRASH_REASON_SOFT_WATCHDOG; + } + out.core_count = 1; + out.cores[0].count = esp8266::read_rtc_backtrace(out.cores[0].addresses.data(), MAX_BACKTRACE); + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + // --- Custom crash callback --- // Overrides the weak custom_crash_callback() from Arduino core's // core_esp8266_postmortem.cpp. Called during exception handling before diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index d974a5c47fe..4e45f2d91d2 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -1,4 +1,5 @@ #include "ezo.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include "esphome/core/progmem.h" @@ -8,7 +9,57 @@ namespace esphome::ezo { PROGMEM_STRING_TABLE(EzoCommandTypeStrings, "EZO_READ", "EZO_LED", "EZO_DEVICE_INFORMATION", "EZO_SLOPE", "EZO_CALIBRATION", "EZO_SLEEP", "EZO_I2C", "EZO_T", "EZO_CUSTOM"); -PROGMEM_STRING_TABLE(EzoCalibrationTypeStrings, "LOW", "MID", "HIGH"); +// Indexed by EzoCommandId, up to I2C; ids from CAL_LOW to I2C get their value appended +PROGMEM_STRING_TABLE(EzoCommandTextStrings, "i", "Sleep", "Slope,?", "T,?", "Cal,?", "Cal,clear", "L,?", "L,0", "L,1", + "R", "Cal,LOW,", "Cal,MID,", "Cal,HIGH,", "Cal,", "T,", "I2C,"); +static_assert(EzoCommandTextStrings::COUNT == static_cast(EzoCommandId::EZO_COMMAND_ID_CUSTOM)); +static_assert(EzoCommandTextStrings::MAX_LENGTH <= EZO_MAX_COMMAND_LENGTH); + +// Indexed by EzoCommandId +static constexpr uint8_t REPLY_TYPES[] PROGMEM = { + EZO_DEVICE_INFORMATION, + EZO_SLEEP, + EZO_SLOPE, + EZO_T, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_LED, + EZO_LED, + EZO_LED, + EZO_READ, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_T, + EZO_I2C, + EZO_CUSTOM, +}; +static_assert(sizeof(REPLY_TYPES) == static_cast(EzoCommandId::EZO_COMMAND_ID_CUSTOM) + 1); + +static EzoCommandType reply_type(EzoCommandId id) { + return static_cast(progmem_read_byte(&REPLY_TYPES[static_cast(id)])); +} + +static constexpr uint32_t LONG_DELAY_MS = 900; // reads and calibration points +static constexpr uint32_t COMMAND_DELAY_MS = 300; +static constexpr size_t MAX_REPLY_LENGTH = 52; + +static uint32_t reply_delay_ms(EzoCommandId id) { + return id >= EzoCommandId::EZO_COMMAND_ID_READ && id <= EzoCommandId::EZO_COMMAND_ID_CAL_GENERIC ? LONG_DELAY_MS + : COMMAND_DELAY_MS; +} + +// Writes a built-in command's text and returns its length. Values get two decimals and the I2C address none; +// the other commands ignore the value. +static size_t render_command(EzoCommandId id, float value, char *text) { + const int length = ESPHOME_snprintf_P( + text, EZO_MAX_COMMAND_LENGTH + 1, + id >= EzoCommandId::EZO_COMMAND_ID_CAL_LOW ? ESPHOME_PSTR("%s%.*f") : ESPHOME_PSTR("%s"), + LOG_STR_ARG(EzoCommandTextStrings::get_log_str(static_cast(id), EzoCommandTextStrings::LAST_INDEX)), + id == EzoCommandId::EZO_COMMAND_ID_I2C ? 0 : 2, value); + return length < 0 ? 0 : std::min(static_cast(length), EZO_MAX_COMMAND_LENGTH); +} void EZOSensor::dump_config() { LOG_SENSOR("", "EZO", this); @@ -17,73 +68,89 @@ void EZOSensor::dump_config() { } void EZOSensor::update() { - // Check if a read is in there already and if not insert on in the second position - - if (!this->commands_.empty() && this->commands_.front()->command_type != EzoCommandType::EZO_READ && - this->commands_.size() > 1) { - bool found = false; - - for (auto &i : this->commands_) { - if (i->command_type == EzoCommandType::EZO_READ) { - found = true; - break; - } - } - - if (!found) { - auto ezo_command = make_unique(); - ezo_command->command = "R"; - ezo_command->command_type = EzoCommandType::EZO_READ; - ezo_command->delay_ms = 900; - - auto it = this->commands_.begin(); - ++it; - this->commands_.insert(it, std::move(ezo_command)); - } + this->read_pending_ = true; + this->enable_loop(); +} +void EZOSensor::send_next_() { + const uint32_t now = App.get_loop_component_start_time(); + const bool after_read = this->state_ == EzoState::EZO_STATE_IDLE_AFTER_READ; + // A read goes right after the command in flight, but never twice in a row while others wait. + if (this->read_pending_ && (!after_read || this->queue_.empty())) { + ESP_LOGVV(TAG, "Sending command \"R\""); + static constexpr uint8_t READ_COMMAND = 'R'; + this->write(&READ_COMMAND, 1); + this->read_pending_ = false; + this->state_ = EzoState::EZO_STATE_SENT_READ; + this->reply_due_ = now + LONG_DELAY_MS; + return; + } + if (this->queue_.empty()) { + this->disable_loop(); return; } - this->get_state(); + const EzoCommand &command = this->queue_.front(); + const EzoCommandId id = command.id; + char text[EZO_MAX_COMMAND_LENGTH + 1]; + size_t length; + if (id == EzoCommandId::EZO_COMMAND_ID_CUSTOM) { + length = command.text_length; + const size_t first = std::min(length, EZO_CUSTOM_TEXT_SIZE - this->custom_text_start_); + memcpy(text, this->custom_text_ + this->custom_text_start_, first); + memcpy(text + first, this->custom_text_, length - first); + text[length] = '\0'; + } else { + length = render_command(id, command.value, text); + } + ESP_LOGVV(TAG, "Sending command \"%s\"", text); + this->write(reinterpret_cast(text), length); + + if (id == EzoCommandId::EZO_COMMAND_ID_SLEEP || id == EzoCommandId::EZO_COMMAND_ID_I2C) { + // No reply; each queued address change applies its own target + if (id == EzoCommandId::EZO_COMMAND_ID_I2C) + this->address_ = static_cast(command.value); + this->queue_.pop(); + this->state_ = EzoState::EZO_STATE_IDLE; + return; + } + + this->state_ = EzoState::EZO_STATE_SENT_QUEUED; + this->reply_due_ = now + reply_delay_ms(id); +} + +void EZOSensor::finish_command_() { + if (this->state_ == EzoState::EZO_STATE_SENT_READ) { + this->state_ = EzoState::EZO_STATE_IDLE_AFTER_READ; + return; + } + const EzoCommand &command = this->queue_.front(); + if (command.id == EzoCommandId::EZO_COMMAND_ID_CUSTOM) { + this->custom_text_start_ = (this->custom_text_start_ + command.text_length) % EZO_CUSTOM_TEXT_SIZE; + this->custom_text_used_ -= command.text_length; + } + this->queue_.pop(); + this->state_ = EzoState::EZO_STATE_IDLE; } void EZOSensor::loop() { - if (this->commands_.empty()) { + if (this->state_ == EzoState::EZO_STATE_IDLE || this->state_ == EzoState::EZO_STATE_IDLE_AFTER_READ) { + this->send_next_(); return; } - EzoCommand *to_run = this->commands_.front().get(); - - if (!to_run->command_sent) { - const uint8_t *data = reinterpret_cast(to_run->command.c_str()); - ESP_LOGVV(TAG, "Sending command \"%s\"", data); - - this->write(data, to_run->command.length()); - - if (to_run->command_type == EzoCommandType::EZO_SLEEP || - to_run->command_type == EzoCommandType::EZO_I2C) { // Commands with no return data - bool update_address = to_run->command_type == EzoCommandType::EZO_I2C; - this->commands_.pop_front(); - if (update_address) - this->address_ = this->new_address_; - return; - } - - this->start_time_ = millis(); - to_run->command_sent = true; - return; - } - - if (millis() - this->start_time_ < to_run->delay_ms) + if (static_cast(App.get_loop_component_start_time() - this->reply_due_) < 0) return; - uint8_t buf[32]; - + // Response code, then up to 52 characters (the largest documented reply, EZO-RGB), then a terminator + // that is always set, since a reply that fills the read has none. + uint8_t buf[MAX_REPLY_LENGTH + 2]; buf[0] = 0; + buf[MAX_REPLY_LENGTH + 1] = 0; - if (!this->read_bytes_raw(buf, 32)) { + if (!this->read_bytes_raw(buf, MAX_REPLY_LENGTH + 1)) { ESP_LOGE(TAG, "read error"); - this->commands_.pop_front(); + this->finish_command_(); return; } @@ -103,138 +170,148 @@ void EZOSensor::loop() { break; } + const EzoCommandType command_type = + this->state_ == EzoState::EZO_STATE_SENT_READ ? EzoCommandType::EZO_READ : reply_type(this->queue_.front().id); + // Free the slot first so the callbacks below can queue a follow-up + this->finish_command_(); ESP_LOGV(TAG, "Received buffer \"%s\" for command type %s", &buf[1], - LOG_STR_ARG(EzoCommandTypeStrings::get_log_str(to_run->command_type, EzoCommandTypeStrings::LAST_INDEX))); + LOG_STR_ARG(EzoCommandTypeStrings::get_log_str(command_type, EzoCommandTypeStrings::LAST_INDEX))); - if (buf[0] == 1) { - std::string payload = reinterpret_cast(&buf[1]); - if (!payload.empty()) { - auto start_location = payload.find(','); - switch (to_run->command_type) { - case EzoCommandType::EZO_READ: { - // some sensors return multiple comma-separated values, terminate string after first one - if (start_location != std::string::npos) { - payload.erase(start_location); - } - auto val = parse_number(payload); - if (!val.has_value()) { - ESP_LOGW(TAG, "Can't convert '%s' to number!", payload.c_str()); - } else { - this->publish_state(*val); - } - break; - } - case EzoCommandType::EZO_LED: - this->led_callback_.call(payload.back() == '1'); - break; - case EzoCommandType::EZO_DEVICE_INFORMATION: - if (start_location != std::string::npos) { - this->device_infomation_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_SLOPE: - if (start_location != std::string::npos) { - this->slope_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_CALIBRATION: - if (start_location != std::string::npos) { - this->calibration_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_T: - if (start_location != std::string::npos) { - this->t_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_CUSTOM: - this->custom_callback_.call(payload); - break; - default: - break; + char *payload = reinterpret_cast(&buf[1]); + if (buf[0] != 1 || payload[0] == '\0') + return; + char *comma = strchr(payload, ','); + const char *text = comma != nullptr ? comma + 1 : nullptr; + CallbackManager *callback = nullptr; + switch (command_type) { + case EzoCommandType::EZO_READ: { + // some sensors return multiple comma-separated values, terminate string after first one + if (comma != nullptr) + *comma = '\0'; + auto val = parse_number(payload); + if (!val.has_value()) { + ESP_LOGW(TAG, "Can't convert '%s' to number!", payload); + } else { + this->publish_state(*val); } + return; } + case EzoCommandType::EZO_LED: + this->led_callback_.call(payload[strlen(payload) - 1] == '1'); + return; + case EzoCommandType::EZO_DEVICE_INFORMATION: + callback = &this->device_infomation_callback_; + break; + case EzoCommandType::EZO_SLOPE: + callback = &this->slope_callback_; + break; + case EzoCommandType::EZO_CALIBRATION: + callback = &this->calibration_callback_; + break; + case EzoCommandType::EZO_T: + callback = &this->t_callback_; + break; + case EzoCommandType::EZO_CUSTOM: + callback = &this->custom_callback_; + text = payload; + break; + default: + return; } - this->commands_.pop_front(); + if (text != nullptr) + callback->call(std::string(text)); } -void EZOSensor::add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms) { - auto ezo_command = make_unique(); - ezo_command->command = command; - ezo_command->command_type = command_type; - ezo_command->delay_ms = delay_ms; - this->commands_.push_back(std::move(ezo_command)); -} - -void EZOSensor::set_calibration_point_(EzoCalibrationType type, float value) { - // max 21: "Cal,"(4) + type(4) + ","(1) + float(11) + null; use 24 for safety - char payload[24]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("Cal,%s,%0.2f"), - LOG_STR_ARG(EzoCalibrationTypeStrings::get_log_str(type, EzoCalibrationTypeStrings::LAST_INDEX)), - value); - this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); +void EZOSensor::add_command_(EzoCommandId id, float value) { + if (!this->queue_.push(EzoCommand{id, {value}})) { + ESP_LOGW( + TAG, "Command queue full, dropped \"%s\"", + LOG_STR_ARG(EzoCommandTextStrings::get_log_str(static_cast(id), EzoCommandTextStrings::LAST_INDEX))); + return; + } + this->enable_loop(); } void EZOSensor::set_address(uint8_t address) { if (address > 0 && address < 128) { - // max 8: "I2C,"(4) + uint8(3) + null - char payload[8]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("I2C,%u"), address); - this->new_address_ = address; - this->add_command_(payload, EzoCommandType::EZO_I2C); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_I2C, address); } else { ESP_LOGE(TAG, "Invalid I2C address"); } } -void EZOSensor::get_device_information() { this->add_command_("i", EzoCommandType::EZO_DEVICE_INFORMATION); } +void EZOSensor::get_device_information() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_DEVICE_INFORMATION); } -void EZOSensor::set_sleep() { this->add_command_("Sleep", EzoCommandType::EZO_SLEEP); } +void EZOSensor::set_sleep() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_SLEEP); } -void EZOSensor::get_state() { this->add_command_("R", EzoCommandType::EZO_READ, 900); } +void EZOSensor::get_state() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_READ); } -void EZOSensor::get_slope() { this->add_command_("Slope,?", EzoCommandType::EZO_SLOPE); } +void EZOSensor::get_slope() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_SLOPE); } -void EZOSensor::get_t() { this->add_command_("T,?", EzoCommandType::EZO_T); } +void EZOSensor::get_t() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_T_GET); } void EZOSensor::set_t(float value) { - // max 14 bytes: "T,"(2) + float with "%0.2f" (up to 11 chars) + null(1); use 16 for alignment - char payload[16]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("T,%0.2f"), value); - this->add_command_(payload, EzoCommandType::EZO_T); + // A newer value replaces one that is still waiting to be sent + bool in_flight = this->state_ == EzoState::EZO_STATE_SENT_QUEUED; + for (EzoCommand &command : this->queue_) { + if (!in_flight && command.id == EzoCommandId::EZO_COMMAND_ID_T_SET) { + command.value = value; + return; + } + in_flight = false; + } + this->add_command_(EzoCommandId::EZO_COMMAND_ID_T_SET, value); } void EZOSensor::set_tempcomp_value(float temp) { this->set_t(temp); } -void EZOSensor::get_calibration() { this->add_command_("Cal,?", EzoCommandType::EZO_CALIBRATION); } +void EZOSensor::get_calibration() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_GET); } void EZOSensor::set_calibration_point_low(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_LOW, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_LOW, value); } void EZOSensor::set_calibration_point_mid(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_MID, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_MID, value); } void EZOSensor::set_calibration_point_high(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_HIGH, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_HIGH, value); } void EZOSensor::set_calibration_generic(float value) { - // exact 16 bytes: "Cal," (4) + float with "%0.2f" (up to 11 chars, e.g. "-9999999.99") + null (1) = 16 - char payload[16]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("Cal,%0.2f"), value); - this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_GENERIC, value); } -void EZOSensor::clear_calibration() { this->add_command_("Cal,clear", EzoCommandType::EZO_CALIBRATION); } +void EZOSensor::clear_calibration() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_CLEAR); } -void EZOSensor::get_led_state() { this->add_command_("L,?", EzoCommandType::EZO_LED); } +void EZOSensor::get_led_state() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_LED_GET); } -void EZOSensor::set_led_state(bool on) { this->add_command_(on ? "L,1" : "L,0", EzoCommandType::EZO_LED); } +void EZOSensor::set_led_state(bool on) { + this->add_command_(on ? EzoCommandId::EZO_COMMAND_ID_LED_ON : EzoCommandId::EZO_COMMAND_ID_LED_OFF); +} void EZOSensor::send_custom(const std::string &to_send) { - this->add_command_(to_send.c_str(), EzoCommandType::EZO_CUSTOM); + const size_t length = to_send.size(); + if (length > EZO_MAX_COMMAND_LENGTH) { + ESP_LOGW(TAG, "Command too long (%u > %u), dropped", static_cast(length), + static_cast(EZO_MAX_COMMAND_LENGTH)); + return; + } + if (this->queue_.size() >= EZO_COMMAND_QUEUE_SIZE || this->custom_text_used_ + length > EZO_CUSTOM_TEXT_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropped \"%s\"", to_send.c_str()); + return; + } + // Append after the queued texts, wrapping around the end of the ring + const size_t end = (this->custom_text_start_ + this->custom_text_used_) % EZO_CUSTOM_TEXT_SIZE; + const size_t first = std::min(length, EZO_CUSTOM_TEXT_SIZE - end); + memcpy(this->custom_text_ + end, to_send.data(), first); + memcpy(this->custom_text_, to_send.data() + first, length - first); + this->custom_text_used_ += length; + EzoCommand command{EzoCommandId::EZO_COMMAND_ID_CUSTOM, {}}; + command.text_length = length; + this->queue_.push(command); + this->enable_loop(); } } // namespace esphome::ezo diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index 7a3e84f56ca..37746a2828a 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include namespace esphome::ezo { @@ -22,14 +23,49 @@ enum EzoCommandType : uint8_t { EZO_CUSTOM }; -enum EzoCalibrationType : uint8_t { EZO_CAL_LOW = 0, EZO_CAL_MID = 1, EZO_CAL_HIGH = 2 }; +/// Longest command text, without the terminator. The longest documented command is the datasheet's +/// "Import, 59 6F 75 20 61 72" (25). Longer custom commands are dropped. +static constexpr size_t EZO_MAX_COMMAND_LENGTH = 25; +/// Total command slots, including the one in flight. The periodic read does not take a slot. +static constexpr uint8_t EZO_COMMAND_QUEUE_SIZE = 8; +/// Bytes shared by the texts of all queued custom commands, stored back to back without terminators. +static constexpr uint8_t EZO_CUSTOM_TEXT_SIZE = 64; -class EzoCommand { - public: - std::string command; - uint16_t delay_ms = 0; - bool command_sent = false; - EzoCommandType command_type; +/// Each built-in command; the text is rendered only when it is sent. The order matches the tables in ezo.cpp. +enum class EzoCommandId : uint8_t { + EZO_COMMAND_ID_DEVICE_INFORMATION, + EZO_COMMAND_ID_SLEEP, + EZO_COMMAND_ID_SLOPE, + EZO_COMMAND_ID_T_GET, + EZO_COMMAND_ID_CAL_GET, + EZO_COMMAND_ID_CAL_CLEAR, + EZO_COMMAND_ID_LED_GET, + EZO_COMMAND_ID_LED_OFF, + EZO_COMMAND_ID_LED_ON, + EZO_COMMAND_ID_READ, // READ to CAL_GENERIC wait 900 ms for the reply + EZO_COMMAND_ID_CAL_LOW, // CAL_LOW to I2C send their value + EZO_COMMAND_ID_CAL_MID, + EZO_COMMAND_ID_CAL_HIGH, + EZO_COMMAND_ID_CAL_GENERIC, + EZO_COMMAND_ID_T_SET, + EZO_COMMAND_ID_I2C, + EZO_COMMAND_ID_CUSTOM, // the text is in EZOSensor::custom_text_ +}; + +struct __attribute__((packed)) EzoCommand { + EzoCommandId id; + union __attribute__((packed)) { + float value; // CAL_* and T_SET value, or the I2C address + uint8_t text_length; // CUSTOM: length of its text in EZOSensor::custom_text_ + }; +}; +static_assert(sizeof(EzoCommand) == 5, "a queue slot holds the command id and one float"); + +enum class EzoState : uint8_t { + EZO_STATE_IDLE, + EZO_STATE_IDLE_AFTER_READ, // a pending read waits for one queued command first + EZO_STATE_SENT_QUEUED, // the queue's front command is waiting for its reply + EZO_STATE_SENT_READ, }; /// This class implements support for the EZO circuits in i2c mode @@ -85,21 +121,26 @@ class EZOSensor final : public sensor::Sensor, public PollingComponent, public i template void add_custom_callback(F &&callback) { this->custom_callback_.add(std::forward(callback)); } protected: - std::deque> commands_; - int new_address_; + void add_command_(EzoCommandId id, float value = 0.0f); + void send_next_(); + void finish_command_(); - void add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms = 300); - - void set_calibration_point_(EzoCalibrationType type, float value); - - CallbackManager device_infomation_callback_{}; - CallbackManager calibration_callback_{}; - CallbackManager slope_callback_{}; - CallbackManager t_callback_{}; - CallbackManager custom_callback_{}; + CallbackManager device_infomation_callback_{}; + CallbackManager calibration_callback_{}; + CallbackManager slope_callback_{}; + CallbackManager t_callback_{}; + CallbackManager custom_callback_{}; CallbackManager led_callback_{}; - uint32_t start_time_ = 0; + /// Queued commands; the front one stays in place while it is in flight. The periodic read is not queued. + StaticRingBuffer queue_; + uint32_t reply_due_{0}; // when the reply to the command in flight can be read + EzoState state_{EzoState::EZO_STATE_IDLE}; + bool read_pending_{false}; // a periodic read was requested and is not yet sent + /// Ring of queued custom texts in queue order; the oldest starts at custom_text_start_. + uint8_t custom_text_start_{0}; + uint8_t custom_text_used_{0}; + char custom_text_[EZO_CUSTOM_TEXT_SIZE]; }; } // namespace esphome::ezo diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 0c2f1b25a78..735ae76d552 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -66,10 +66,10 @@ void FUSB302B::setup() { this->interrupt_pin_->setup(); // The task must exist before the interrupt is attached, since the ISR notifies it - if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { - this->mark_failed(LOG_STR("Failed to create task")); + if (!this->setup_check( + this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false), + LOG_STR("Failed to create task"))) return; - } this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index ddb9e63686f..bddcc28d4bf 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -9,6 +9,9 @@ namespace esphome::gpio_expander { +/// Pin number type for an expander with N pins. +template using expander_pin_t = std::conditional_t<(N > 256), uint16_t, uint8_t>; + /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. /// This means that for reading whole Port (ex. 8 pins) component needs only one @@ -25,21 +28,25 @@ namespace esphome::gpio_expander { /// P - Type for pin number parameters (automatically selected based on N: /// uint8_t for N<=256, uint16_t for N>256). Can be explicitly specified /// if needed (e.g., for components like SN74HC165 with >256 pins) -template 256), uint16_t, uint8_t>::type> -class CachedGpioExpander { +/// +/// CachedGpioExpanderBase holds the cache; CachedGpioExpander adds the invalidate-on-read flag, and +/// CachedGpioExpanderComponent takes it from the interrupt pin instead. +template> class CachedGpioExpanderBase { public: - /// @brief Read the state of the given pin. - /// By default, each read invalidates the pin's cache entry so the next read - /// of the same pin triggers a fresh hardware read. When invalidate_on_read - /// is disabled, the cache stays valid until explicitly cleared via reset_pin_cache_(). - /// @param pin Pin number to read - /// @return Pin state - bool digital_read(P pin) { + void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } + + protected: + using bank_t = T; + using pin_t = P; + + /// Read pin through the bank cache. With invalidate_on_read the pin is read from hardware again next + /// time; otherwise the bank stays cached until reset_pin_cache_(). + bool cached_read_(P pin, bool invalidate_on_read) { const P bank = pin / BANK_SIZE; - const T pin_mask = (1 << (pin % BANK_SIZE)); + const T pin_mask = T(1) << (pin % BANK_SIZE); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { - if (this->invalidate_on_read_) { + if (invalidate_on_read) { // Invalidate pin so next read triggers hardware read this->read_cache_valid_[bank] &= ~pin_mask; } @@ -49,14 +56,11 @@ class CachedGpioExpander { return false; // Mark bank cache as valid except the pin that is being returned now // (when not invalidating on read, mark all pins including this one as valid) - this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(this->invalidate_on_read_ ? pin_mask : 0); + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(invalidate_on_read ? pin_mask : 0); } return this->digital_read_cache(pin); } - void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } - - protected: /// @brief Read GPIO bank from hardware into internal state /// @param pin Pin number (used to determine which bank to read) /// @return true if read succeeded, false on communication error @@ -77,18 +81,45 @@ class CachedGpioExpander { /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. - /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. - /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. - void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } - static constexpr uint16_t BITS_PER_BYTE = 8; static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); T read_cache_valid_[BANKS]{0}; +}; + +/// A CachedGpioExpanderBase whose digital_read() invalidates each pin it returns unless told otherwise. +template> +class CachedGpioExpander : public CachedGpioExpanderBase { + public: + bool digital_read(P pin) { return this->cached_read_(pin, this->invalidate_on_read_); } + + protected: + /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. + /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. + /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. + void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } + bool invalidate_on_read_{true}; }; +/// Adds a store of the last value read from each bank to a read cache Base. digital_read_hw() stores the +/// bank it reads in input_[pin / BANK_SIZE] (a chip read in one transfer is one bank, input_[0]), and +/// digital_read_cache() is provided. +template class GpioExpanderInputs : public Base { + protected: + using typename Base::bank_t; + using typename Base::pin_t; + using Base::BANK_SIZE; + using Base::BANKS; + + bool digital_read_cache(pin_t pin) override { + return this->input_[pin / BANK_SIZE] & (bank_t(1) << (pin % BANK_SIZE)); + } + + /// The input state per bank - 1 means HIGH, 0 means LOW + bank_t input_[BANKS]{}; +}; + } // namespace esphome::gpio_expander diff --git a/esphome/components/gpio_expander/cached_gpio_component.cpp b/esphome/components/gpio_expander/cached_gpio_component.cpp index 7f32ce07d67..0be2a393b37 100644 --- a/esphome/components/gpio_expander/cached_gpio_component.cpp +++ b/esphome/components/gpio_expander/cached_gpio_component.cpp @@ -5,18 +5,17 @@ namespace esphome::gpio_expander { // Not a member of the template: GCC 10 (ESP8266) ignores section attributes on template instantiations. static void IRAM_ATTR interrupt_pin_isr(Component *arg) { arg->enable_loop_soon_any_context(); } -bool InterruptExpanderComponent::attach_interrupt_pin_() { +void InterruptExpanderComponent::setup_interrupt_pin_() { // Polling mode enables the loop in pin_mode() for the first input; interrupt mode from the ISR. this->disable_loop(); if (this->interrupt_pin_ == nullptr) - return false; + return; this->interrupt_pin_->setup(); this->interrupt_pin_->attach_interrupt(&interrupt_pin_isr, static_cast(this), gpio::INTERRUPT_FALLING_EDGE); // An INT line already low at attach gives no falling edge, so read the inputs now if (!this->interrupt_pin_->digital_read()) this->enable_loop(); - return true; } // Input changes that straddle the read leave INT asserted without a new falling edge, so keep looping diff --git a/esphome/components/gpio_expander/cached_gpio_component.h b/esphome/components/gpio_expander/cached_gpio_component.h index 1d748c9bbeb..adaf9ea57a2 100644 --- a/esphome/components/gpio_expander/cached_gpio_component.h +++ b/esphome/components/gpio_expander/cached_gpio_component.h @@ -14,9 +14,9 @@ class InterruptExpanderComponent : public Component { float get_setup_priority() const override { return setup_priority::IO; } protected: - /// Attach the falling-edge ISR when there is an interrupt pin, then disable the loop until an input - /// needs it. Returns whether the interrupt pin is in use. - bool attach_interrupt_pin_(); + /// Call at the end of setup(): attach the falling-edge ISR when there is an interrupt pin, then disable the + /// loop until an input needs it. + void setup_interrupt_pin_(); /// The loop runs again after the INT line falls; stop it once the line is released (HIGH). /// Only called with an interrupt pin. void disable_loop_when_released_(); @@ -29,22 +29,19 @@ class InterruptExpanderComponent : public Component { InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Base for expanders that cache reads per loop. With an interrupt pin the cache stays valid until INT fires. +/// Base for expanders that cache reads per loop, with the per-bank input store of GpioExpanderInputs. Without +/// an interrupt pin each read invalidates its pin; with one the cache stays valid until INT fires. template -class CachedGpioExpanderComponent : public InterruptExpanderComponent, public CachedGpioExpander { +class CachedGpioExpanderComponent : public InterruptExpanderComponent, + public GpioExpanderInputs> { public: + bool digital_read(expander_pin_t pin) { return this->cached_read_(pin, this->interrupt_pin_ == nullptr); } + void loop() override { this->reset_pin_cache_(); if (this->interrupt_pin_ != nullptr) this->disable_loop_when_released_(); } - - protected: - /// Call at the end of setup(). - void setup_interrupt_pin_() { - if (this->attach_interrupt_pin_()) - this->set_invalidate_on_read_(false); - } }; } // namespace esphome::gpio_expander diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 4f83f31092d..2f4e716cb21 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .. import gree_ns @@ -82,7 +83,10 @@ def _feature_switch_schema( entity_category=ENTITY_CATEGORY_CONFIG, ) + @schema_extractor("schema") def validator(value: ConfigType) -> ConfigType: + if value is SCHEMA_EXTRACT: + return schema explicit = isinstance(value, dict) and any( key in value for key in _RESTORE_KEYS ) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index 7233bbee950..52126b16082 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -23,12 +23,7 @@ void GroveMotorDriveTB6612FNG::dump_config() { LOG_I2C_DEVICE(this); } -void GroveMotorDriveTB6612FNG::setup() { - if (!this->standby()) { - this->mark_failed(); - return; - } -} +void GroveMotorDriveTB6612FNG::setup() { this->setup_check(this->standby()); } bool GroveMotorDriveTB6612FNG::standby() { uint8_t status = 0; diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp index ff2fbde18f7..55f1e1a0ec5 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -68,10 +68,8 @@ void I2SClocklessLedStrip::dump_config() { float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } void I2SClocklessLedStrip::setup() { - if (!this->allocate_buffers_()) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->allocate_buffers_(), LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } i2s_chan_config_t chan_config = { .id = I2S_NUM_AUTO, diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp index b97841d0df0..93eeec62400 100644 --- a/esphome/components/ir_rf_base/ir_rf_base.cpp +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -120,7 +120,7 @@ bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) { #else uint32_t device_id = 0; #endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 276f91eed59..27db82d557f 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -19,7 +19,7 @@ static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; // --- Loop / scheduling ------------------------------------------------------- void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { - if (!this->queue_.push_back(Op{type, a, b})) { + if (!this->queue_.push(Op{type, a, b})) { ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), static_cast(type)); } @@ -74,7 +74,7 @@ void IT8951Display::loop() { return; } - this->queue_.pop_front(); + this->queue_.pop(); this->process_op_(queued_op); } @@ -330,10 +330,8 @@ void IT8951Display::setup() { this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(this->buffer_length_); - if (this->buffer_ == nullptr) { - this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + if (!this->setup_check(this->buffer_ != nullptr, LOG_STR("Failed to allocate IT8951 framebuffer"))) return; - } // The allocator does not zero memory; start blank (white) so undrawn regions // (e.g. with auto_clear disabled) don't show garbage on the first update. this->fill(Color::WHITE); diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index 2b5f8d299fc..6f7088b7e3b 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -15,59 +15,7 @@ namespace esphome::it8951 { using namespace display; -// --- Bounded op queue -------------------------------------------------------- -// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque -// to comply with ESPHome's STL container guidelines (std::deque allocates in -// 512-byte blocks regardless of element size). Size analysis: the deepest -// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's -// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a -// comfortable margin while keeping RAM cost low (~192 bytes per instance vs -// 512+ bytes for std::deque). -template class StaticOpQueue { - public: - bool empty() const { return this->count_ == 0; } - size_t size() const { return this->count_; } - static constexpr size_t capacity() { return N; } - - bool push_back(const T &value) { - if (this->count_ >= N) - return false; - this->data_[(this->head_ + this->count_) % N] = value; - ++this->count_; - return true; - } - - bool push_front(const T &value) { - if (this->count_ >= N) - return false; - this->head_ = (this->head_ + N - 1) % N; - this->data_[this->head_] = value; - ++this->count_; - return true; - } - - void pop_front() { - if (this->count_ == 0) - return; - this->head_ = (this->head_ + 1) % N; - --this->count_; - } - - const T &front() const { return this->data_[this->head_]; } - T &front() { return this->data_[this->head_]; } - - void clear() { - this->head_ = 0; - this->count_ = 0; - } - - private: - T data_[N]{}; - size_t head_{0}; - size_t count_{0}; -}; - -// Op queue capacity. See StaticOpQueue comment for sizing analysis. +// Deepest observed: UPDATE_REFRESH (10 ops) + 5 CHECK_LUT_IDLE reschedules = 14; 32 for margin static constexpr size_t OP_QUEUE_SIZE = 32; // --- Op queue --------------------------------------------------------------- @@ -278,7 +226,7 @@ class IT8951Display : public Display, // --- State --- static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; - StaticOpQueue queue_; + StaticRingBuffer queue_; Phase phase_{Phase::IDLE}; uint32_t delay_until_{0}; uint32_t phase_started_at_{0}; diff --git a/esphome/components/ld2410/number.py b/esphome/components/ld2410/number.py index 85bf8647fbd..ff5f1964647 100644 --- a/esphome/components/ld2410/number.py +++ b/esphome/components/ld2410/number.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_TIMEOUT, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_SIGNAL_STRENGTH, ENTITY_CATEGORY_CONFIG, ICON_LIGHTBULB, ICON_MOTION_SENSOR, @@ -65,14 +64,12 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), cv.Required(CONF_STILL_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, diff --git a/esphome/components/ld2412/number.py b/esphome/components/ld2412/number.py index b1e556d9d2d..98ee135121b 100644 --- a/esphome/components/ld2412/number.py +++ b/esphome/components/ld2412/number.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_TIMEOUT, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_SIGNAL_STRENGTH, ENTITY_CATEGORY_CONFIG, ICON_LIGHTBULB, ICON_MOTION_SENSOR, @@ -65,14 +64,12 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Required(CONF_STILL_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index e025f0dc50c..5f76097bc19 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -187,10 +187,8 @@ void LD600XComponent::setup() { // One allocation for the component lifetime; the parser reuses it for every payload. RAMAllocator allocator; uint8_t *buffer = allocator.allocate(capacity); - if (buffer == nullptr) { - this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + if (!this->setup_check(buffer != nullptr, LOG_STR("Failed to allocate frame buffer"))) return; - } this->parser_.init(buffer, capacity); this->set_timeout(SETUP_DELAY_MS, [this]() { diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e8ab39954b8..c905a1671da 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -568,6 +568,8 @@ async def component_to_code(config): # Crash handler (crash_handler.cpp) cg.add_define("USE_LIBRETINY_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") + cg.add_build_flag("-Wl,--wrap=abort") if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): for trap in BK_TRAP_HANDLERS: cg.add_build_flag(f"-Wl,--wrap={trap}") diff --git a/esphome/components/libretiny/crash_handler.cpp b/esphome/components/libretiny/crash_handler.cpp index 77b4a7e57ba..24e8ab41438 100644 --- a/esphome/components/libretiny/crash_handler.cpp +++ b/esphome/components/libretiny/crash_handler.cpp @@ -10,6 +10,8 @@ #endif #include "core.h" +#include +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -96,8 +98,10 @@ static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2; static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3; // Cortex-M families: the exception number (3 to 7) plus this offset. static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1; +// After the Cortex-M ones (4 to 8). +static constexpr uint32_t CRASH_REASON_ABORT = 9; PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort", - "Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault"); + "Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault", "Abort"); static constexpr size_t MAX_BACKTRACE = 8; // Words scanned above sp for return addresses. @@ -155,7 +159,10 @@ void crash_handler_log() { ESP_LOGE(TAG, " CFSR: 0x%08" PRIX32 " Address: 0x%08" PRIX32, s_record.fault_status, s_record.fault_address); } ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc); - ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr); + // 0 for abort(), and for a Cortex-M frame outside SRAM + if (s_record.lr != 0) { + ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr); + } ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp); uint8_t count = 0; for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) { @@ -163,8 +170,9 @@ void crash_handler_log() { } // addr2line hint with every address char hint[160]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32, - s_record.pc, s_record.lr); + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32, s_record.pc); + if (s_record.lr != 0) + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.lr); for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]); } @@ -190,6 +198,16 @@ static inline bool is_code_addr(uint32_t val) { #endif } +static void fill_record(uint32_t reason, uint32_t pc, uint32_t lr, uint32_t sp, uint32_t fault_status, + uint32_t fault_address) { + s_record.reason = reason; + s_record.pc = pc; + s_record.lr = lr; + s_record.sp = sp; + s_record.fault_status = fault_status; + s_record.fault_address = fault_address; +} + // Return addresses above sp give the backtrace; then the magic marks the record complete. static void finish_record(uint32_t sp, uint32_t sram_start, uint32_t sram_end) { uint32_t count = 0; @@ -264,12 +282,7 @@ static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offse uint32_t lr = 0; read_saved_sp_lr(mode, &sp, &lr); - s_record.reason = reason; - s_record.pc = regs->pc - pc_offset; - s_record.lr = lr; - s_record.sp = sp; - s_record.fault_status = 0; - s_record.fault_address = 0; + fill_record(reason, regs->pc - pc_offset, lr, sp, 0, 0); finish_record(sp, SRAM_START, SRAM_END); lt_wdt_enable(CRASH_REBOOT_WDT_MS); @@ -366,22 +379,17 @@ extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *f } else if (cfsr & CFSR_MMARVALID) { address = read_reg(SCB_MMFAR); } - s_record.reason = vector + CRASH_REASON_CORTEX_M_OFFSET; - s_record.fault_status = cfsr; - s_record.fault_address = address; - auto frame_addr = (uint32_t) frame; uint32_t frame_words = (exc_return & EXC_RETURN_STANDARD_FRAME) ? FRAME_WORDS : EXTENDED_FRAME_WORDS; + uint32_t pc = 0; + uint32_t lr = 0; uint32_t sp = 0; if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - frame_words * 4 && (frame_addr & 3) == 0) { - s_record.pc = frame[FRAME_PC]; - s_record.lr = frame[FRAME_LR]; + pc = frame[FRAME_PC]; + lr = frame[FRAME_LR]; sp = frame_addr + frame_words * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0); - } else { - s_record.pc = 0; - s_record.lr = 0; } - s_record.sp = sp; + fill_record(vector + CRASH_REASON_CORTEX_M_OFFSET, pc, lr, sp, cfsr, address); finish_record(sp, SRAM_START, SRAM_END); return s_sdk_fault_vectors[vector - FIRST_FAULT_VECTOR]; } @@ -416,7 +424,79 @@ void crash_handler_install() { #endif // USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH +// newlib's abort() spins in _exit() with no record and, on the BK7238, no watchdog reset. Interrupts go off first +// because every task's delay() feeds the watchdog. +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" [[noreturn]] void __wrap_abort() { + portDISABLE_INTERRUPTS(); + lt_wdt_enable(CRASH_REBOOT_WDT_MS); + uint32_t sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + // Inside the bl that called abort(): a noreturn call can be the caller's last instruction, so the return + // address may already be the next function. Bit 0 is the Thumb bit. + fill_record(CRASH_REASON_ABORT, ((uint32_t) __builtin_return_address(0) & ~1u) - 2, 0, sp, 0, 0); + // Valid without a backtrace, so a fault in the stack scan still leaves this record; every entry is cleared, so + // a scan cut short leaves none from an earlier crash + for (auto &addr : s_record.backtrace) + addr = 0; + s_record.magic = CRASH_MAGIC_V2; + // Keeps those writes ahead of the scan's reads + __asm volatile("" ::: "memory"); +#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH + // A fault in the scan then spins for the watchdog instead of replacing this record or running the SDK dump + s_fault_recorded = true; +#endif + finish_record(sp, SRAM_START, SRAM_END); + while (true) { + } +} + } // namespace esphome::libretiny +namespace esphome { + +static_assert(libretiny::MAX_BACKTRACE + 1 <= CRASH_REPORT_MAX_BACKTRACE, + "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !libretiny::crash_handler_has_data()) + return false; + const auto &record = libretiny::s_record; + out = {}; + // The record has no build stamp; like ESP8266 and RP2, assume the running firmware wrote it + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + if (record.reason == libretiny::CRASH_REASON_ABORT) { + out.reason = CrashReason::CRASH_REASON_ABORT; + } else { + out.reason = record.reason != 0 ? CrashReason::CRASH_REASON_EXCEPTION : CrashReason::CRASH_REASON_UNKNOWN; + } + out.pc = record.pc; +#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH + // Cortex-M CFSR, and the BFAR or MMFAR its valid bits mark (0 is a real address for a null dereference); + // BK72xx records neither + if (record.fault_status != 0) { + out.flags |= CRASH_REPORT_FLAG_CAUSE; + out.cause = record.fault_status; + } + if (record.fault_status & (libretiny::CFSR_BFARVALID | libretiny::CFSR_MMARVALID)) { + out.flags |= CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.fault_address = record.fault_address; + } +#endif + auto &core = out.cores[out.core_count++]; + // 0 for abort(), and for a Cortex-M frame outside SRAM + if (record.lr != 0) + core.addresses[core.count++] = record.lr; + for (size_t i = 0; i < libretiny::MAX_BACKTRACE && record.backtrace[i] != 0; i++) + core.addresses[core.count++] = record.backtrace[i]; + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + #endif // USE_LIBRETINY_CRASH_HANDLER #endif // USE_LIBRETINY diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.cpp b/esphome/components/ln882x_spi_led_strip/led_strip.cpp index 42d4d831a3e..b00f99d1aea 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.cpp +++ b/esphome/components/ln882x_spi_led_strip/led_strip.cpp @@ -110,10 +110,9 @@ void LN882xSPILEDStripLightOutput::setup() { // One block for the color, effect and DMA data. RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_, this->dma_data_bytes_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_, this->dma_data_bytes_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } this->dma_data_ = this->internal_accessor_.effect_data + this->num_leds_; spi_dma_init(this->pin_, this->prescaler_); diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c97d90475c4..ee04666d889 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -618,6 +618,8 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP32_LOG_V2") # --wrap: a strong definition collides; see logger_esp32.cpp cg.add_build_flag("-Wl,--wrap=esp_log_format") + if CORE.is_rp2 and config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") # Define platform support flags for components that need auto-detection try: uart_selection(USB_SERIAL_JTAG) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 05f1849a943..c02320b1e55 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -151,6 +151,9 @@ class Logger final : public Component { explicit Logger(uint32_t baud_rate); #if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) void loop() override; +#endif +#if defined(USE_RP2_CRASH_HANDLER) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) + void setup() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 6d190df5cdb..20880b74d70 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -10,6 +10,20 @@ namespace esphome::logger { ESPHOME_LOG_TAG(TAG, "logger"); +#if defined(USE_RP2_CRASH_HANDLER) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) +void Logger::setup() { + if (this->hw_serial_ == nullptr || !crash_log_pending()) + return; + // Report the crash each time a host opens the USB port (sets DTR) + this->set_interval(500, [opened_before = false]() mutable { + const bool opened = Serial.dtr(); + if (opened && !opened_before) + log_crash_to_client(); + opened_before = opened; + }); +} +#endif + void Logger::pre_setup() { if (this->baud_rate_ > 0) { switch (this->uart_) { @@ -29,7 +43,8 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); -#ifdef USE_RP2_CRASH_HANDLER + // Nobody has a USB port open this early; setup() reports the crash once a host opens it +#if defined(USE_RP2_CRASH_HANDLER) && !defined(USE_LOGGER_UART_SELECTION_USB_CDC) rp2::crash_handler_log(); #endif } diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index cd7c2943995..bfc8b8a5ac0 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -26,9 +26,8 @@ void MCP23016::setup() { this->setup_interrupt_pin_(); } -bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } +bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_[0]); } -bool MCP23016::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin, value, MCP23016_OLAT1); } void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 1c2d270f916..ec547bf9499 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -35,7 +35,6 @@ class MCP23016 final : public gpio_expander::CachedGpioExpanderComponent; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 4eb4946b93e..91795c18e5e 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -7,7 +7,7 @@ namespace esphome::mcp23x08_base { ESPHOME_LOG_TAG(TAG, "mcp23x08_base"); bool MCP23X08Base::digital_read_hw(uint8_t pin) { - if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { + if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_[0])) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } @@ -19,8 +19,6 @@ void MCP23X08Base::digital_write_hw(uint8_t pin, bool value) { this->update_reg(pin, value, reg_addr); } -bool MCP23X08Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = mcp23x08_base::MCP23X08_IODIR; uint8_t gppu = mcp23x08_base::MCP23X08_GPPU; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.h b/esphome/components/mcp23x08_base/mcp23x08_base.h index 0d9fefaf948..88e98994bf1 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.h +++ b/esphome/components/mcp23x08_base/mcp23x08_base.h @@ -25,7 +25,6 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { public: bool digital_read_hw(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - bool digital_read_cache(uint8_t pin) override; void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -34,9 +33,6 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) override; uint8_t olat_{0x00}; - - /// State read in digital_read_hw - uint8_t input_mask_{0x00}; }; } // namespace esphome::mcp23x08_base diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 1e430eb002c..4f650625b0e 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -8,19 +8,11 @@ ESPHOME_LOG_TAG(TAG, "mcp23x17_base"); bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; - if (pin < 8) { - if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOA, &data)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - this->input_mask_ = encode_uint16(this->input_mask_ >> 8, data); - } else { - if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOB, &data)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - this->input_mask_ = encode_uint16(data, this->input_mask_ & 0xFF); + if (!this->read_reg(pin < 8 ? mcp23x17_base::MCP23X17_GPIOA : mcp23x17_base::MCP23X17_GPIOB, &data)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; } + this->input_[pin / 8] = data; return true; } @@ -29,8 +21,6 @@ void MCP23X17Base::digital_write_hw(uint8_t pin, bool value) { this->update_reg(pin, value, reg_addr); } -bool MCP23X17Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = pin < 8 ? mcp23x17_base::MCP23X17_IODIRA : mcp23x17_base::MCP23X17_IODIRB; uint8_t gppu = pin < 8 ? mcp23x17_base::MCP23X17_GPPUA : mcp23x17_base::MCP23X17_GPPUB; diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.h b/esphome/components/mcp23x17_base/mcp23x17_base.h index bddfff132de..2820b2ba449 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.h +++ b/esphome/components/mcp23x17_base/mcp23x17_base.h @@ -37,7 +37,6 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { public: bool digital_read_hw(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - bool digital_read_cache(uint8_t pin) override; void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -47,9 +46,6 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { uint8_t olat_a_{0x00}; uint8_t olat_b_{0x00}; - - /// State read in digital_read_hw - uint16_t input_mask_{0x00}; }; } // namespace esphome::mcp23x17_base diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 8c28603e78c..fcf61dca098 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -244,7 +244,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.14.0") + add_idf_component(name="espressif/mdns", ref="1.15.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 937e12e441c..4fe700760e2 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -503,6 +503,7 @@ void MicroWakeWord::loop() { ESP_LOGD(TAG, "Detected '%s' with sliding average probability is %.2f and max probability is %.2f", detection_event.wake_word->c_str(), (detection_event.average_probability / uint8_to_float_divisor), (detection_event.max_probability / uint8_to_float_divisor)); + this->wake_word_detected_callback_.call(detection_event); this->wake_word_detected_trigger_.trigger(*detection_event.wake_word); if (this->stop_after_detection_) { this->stop(); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5aa87cf1b73..bfafd386429 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -68,6 +68,11 @@ class MicroWakeWord final : public Component Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } + /// The event's string pointers are only valid during the call. + template void add_on_wake_word_detected_callback(F &&callback) { + this->wake_word_detected_callback_.add(std::forward(callback)); + } + void add_wake_word_model(WakeWordModel *model); /// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop. @@ -102,6 +107,14 @@ class MicroWakeWord final : public Component // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } + + void set_vad_enabled(bool enabled) { + if (enabled) { + this->vad_model_->enable(); + } else { + this->vad_model_->disable(); + } + } #endif // Intended for the voice assistant component to access which wake words are available @@ -111,6 +124,7 @@ class MicroWakeWord final : public Component protected: microphone::MicrophoneSource *microphone_source_{nullptr}; Trigger wake_word_detected_trigger_; + LazyCallbackManager wake_word_detected_callback_; State state_{State::STOPPED}; std::weak_ptr ring_buffer_; diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 14e6046ba04..fbdf618ce0a 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -374,8 +374,10 @@ void WakeWordModel::disable() { DetectionEvent WakeWordModel::determine_detected() { DetectionEvent detection_event; detection_event.wake_word = &this->wake_word_; + detection_event.id = &this->id_; detection_event.max_probability = 0; detection_event.average_probability = 0; + detection_event.probability_cutoff = this->probability_cutoff_; if ((this->ignore_windows_ < 0) || !this->enabled_) { detection_event.detected = false; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 730afe32bf7..2fe26a94a46 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -20,11 +20,13 @@ static const uint32_t STREAMING_MODEL_VARIABLE_ARENA_SIZE = 1024; struct DetectionEvent { std::string *wake_word; + const std::string *id{nullptr}; bool detected; bool partially_detection; // Set if the most recent probability exceed the threshold, but the sliding window average // hasn't yet uint8_t max_probability; uint8_t average_probability; + uint8_t probability_cutoff{0}; bool blocked_by_vad = false; }; diff --git a/esphome/components/modem/modem_component.cpp b/esphome/components/modem/modem_component.cpp index e4edcee5125..31899e9c775 100644 --- a/esphome/components/modem/modem_component.cpp +++ b/esphome/components/modem/modem_component.cpp @@ -327,10 +327,8 @@ void ModemComponent::setup() { break; } } - if (this->uart_port_ == UART_NUM_MAX) { - this->mark_failed(LOG_STR("No free UART port for modem")); + if (!this->setup_check(this->uart_port_ != UART_NUM_MAX, LOG_STR("No free UART port for modem"))) return; - } esp_netif_config_t netif_ppp_config = ESP_NETIF_DEFAULT_PPP(); this->ppp_netif_ = esp_netif_new(&netif_ppp_config); diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fb33c9d48d3..418190ea1e5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -110,6 +110,7 @@ CONF_WAIT_FOR_CONNECTION = "wait_for_connection" CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions" CONF_TRANSPORT = "transport" CONF_WS_PATH = "ws_path" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" MQTT_TRANSPORT_TCP = "tcp" MQTT_TRANSPORT_WS = "ws" @@ -405,6 +406,14 @@ CONFIG_SCHEMA = cv.All( CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP ): _validate_transport, cv.Optional(CONF_WS_PATH): cv.string, + cv.SplitDefault( + CONF_MAX_PAYLOAD_SIZE, + esp8266=8192, + esp32=32768, + bk72xx=16384, + rtl87xx=16384, + ln882x=16384, + ): cv.All(cv.validate_bytes, cv.int_range(min=512, max=1048576)), cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( cv.boolean, cv.only_on_esp32 ), @@ -664,6 +673,7 @@ async def to_code(config): request_tls() cg.add_define("USE_MQTT") + cg.add_define("MQTT_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) cg.add_global(mqtt_ns.using) cg.add(var.set_broker_address(config[CONF_BROKER])) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 14b26468929..283fe910235 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -235,6 +235,12 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b instance->subacks_.add(mqtt_event->data_len > 0 ? static_cast(mqtt_event->data[0]) : SUBACK_FAILURE); } #endif + if (event_id == MQTT_EVENT_DATA) { + const auto *mqtt_event = static_cast(event_data); + // Only the first chunk of an oversized message is queued, so the client logs the drop + if (mqtt_event->total_data_len > MQTT_MAX_PAYLOAD_SIZE && mqtt_event->current_data_offset != 0) + return; + } // queue event to decouple processing from ESP-IDF MQTT task to main loop auto *event = instance->mqtt_event_pool_.allocate(); if (event == nullptr) { diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 5fcc3b3fb9c..10a918481bb 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -103,6 +103,13 @@ static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { + // total comes from the broker; reserving it unchecked can exhaust the heap + if (total > MQTT_MAX_PAYLOAD_SIZE) { + if (index == 0) { + ESP_LOGW(TAG, "Dropped %zu byte message on '%s' (max %d)", total, topic, MQTT_MAX_PAYLOAD_SIZE); + } + return; + } if (index == 0) { this->payload_buffer_.clear(); this->payload_buffer_.reserve(total); @@ -299,7 +306,10 @@ void MQTTClientComponent::dump_config() { " Discovery retain: %s", this->discovery_info_.prefix.c_str(), YESNO(this->discovery_info_.retain)); } - ESP_LOGCONFIG(TAG, " Topic Prefix: '%s'", this->topic_prefix_.c_str()); + ESP_LOGCONFIG(TAG, + " Topic Prefix: '%s'\n" + " Max Payload Size: %d", + this->topic_prefix_.c_str(), MQTT_MAX_PAYLOAD_SIZE); if (!this->log_message_.topic.empty()) { ESP_LOGCONFIG(TAG, " Log Topic: '%s'", this->log_message_.topic.c_str()); } diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index 552dfe99d30..b4d98862562 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -48,17 +48,24 @@ NdefMessage::NdefMessage(const std::span data) { ESP_LOGVV(TAG, "Lengths: type=%d, payload=%" PRIu32 ", id=%d", type_length, payload_length, id_length); + if (index + type_length > data.size()) { + ESP_LOGE(TAG, "Truncated type; aborting"); + break; + } std::string type_str(data.begin() + index, data.begin() + index + type_length); - index += type_length; std::string id_str; if (il) { + if (index + id_length > data.size()) { + ESP_LOGE(TAG, "Truncated ID; aborting"); + break; + } id_str = std::string(data.begin() + index, data.begin() + index + id_length); index += id_length; } - if ((data.begin() + index > data.end()) || (data.begin() + index + payload_length > data.end())) { + if (payload_length > data.size() - index) { ESP_LOGE(TAG, "Corrupt record encountered; NdefMessage constructor aborting"); break; } diff --git a/esphome/components/packet_interface/hdlc_reliability.cpp b/esphome/components/packet_interface/hdlc_reliability.cpp index cb7fc24b146..0979ef001dd 100644 --- a/esphome/components/packet_interface/hdlc_reliability.cpp +++ b/esphome/components/packet_interface/hdlc_reliability.cpp @@ -34,16 +34,12 @@ HdlcReliability::HdlcReliability(PacketInterface *lower) } void HdlcReliability::setup() { - if (this->max_packet_size_ == 0) { - this->mark_failed(LOG_STR("max_packet_size too small for the configured layers")); + if (!this->setup_check(this->max_packet_size_ != 0, LOG_STR("max_packet_size too small for the configured layers"))) return; - } RAMAllocator allocator; this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_); - if (this->tx_slots_ == nullptr) { - this->mark_failed(LOG_STR("Not enough memory for the send queue")); + if (!this->setup_check(this->tx_slots_ != nullptr, LOG_STR("Not enough memory for the send queue"))) return; - } this->lower_->add_packet_interface_listener( [this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); }); } diff --git a/esphome/components/packet_interface/xxtea_encryption.cpp b/esphome/components/packet_interface/xxtea_encryption.cpp index 62afdad5a28..8057fc7dd1c 100644 --- a/esphome/components/packet_interface/xxtea_encryption.cpp +++ b/esphome/components/packet_interface/xxtea_encryption.cpp @@ -41,17 +41,13 @@ void XxteaEncryption::set_key(std::initializer_list key) { } void XxteaEncryption::setup() { - if (this->max_packet_size_ == 0) { - this->mark_failed(LOG_STR("max_packet_size too small for the configured layers")); + if (!this->setup_check(this->max_packet_size_ != 0, LOG_STR("max_packet_size too small for the configured layers"))) return; - } size_t words = this->lower_->get_max_packet_size() / 4; RAMAllocator allocator; this->tx_buffer_ = allocator.allocate(words * 2); - if (this->tx_buffer_ == nullptr) { - this->mark_failed(LOG_STR("Not enough memory for the packet buffers")); + if (!this->setup_check(this->tx_buffer_ != nullptr, LOG_STR("Not enough memory for the packet buffers"))) return; - } this->rx_buffer_ = this->tx_buffer_ + words; if (this->rolling_code_) { this->pref_ = global_preferences->make_preference(fnv1a_hash_extend(PREF_HASH, this->name_), true); diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index b8ce216ba22..2c3ff02d60c 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -69,18 +69,10 @@ bool PCA6416AComponent::digital_read_hw(uint8_t pin) { if (!this->read_register_(reg_addr, &value)) { return false; } - - // Update the appropriate part of input_mask_ - if (pin < 8) { - this->input_mask_ = (this->input_mask_ & 0xFF00) | value; - } else { - this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); - } + this->input_[pin / 8] = value; return true; } -bool PCA6416AComponent::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void PCA6416AComponent::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? PCA6416A_OUTPUT0 : PCA6416A_OUTPUT1; this->update_register_(pin, value, reg_addr); diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index eef63fe2506..4173262e3ba 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -22,7 +22,6 @@ class PCA6416AComponent final : public gpio_expander::CachedGpioExpanderComponen protected: // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; bool read_register_(uint8_t reg, uint8_t *value); @@ -32,12 +31,10 @@ class PCA6416AComponent final : public gpio_expander::CachedGpioExpanderComponen /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_0_{0x00}; uint8_t output_1_{0x00}; - /// Cache for input values (16-bit combined for both banks) - uint16_t input_mask_{0x00}; - /// Storage for last I2C error seen - esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; + /// Storage for last I2C error seen + esphome::i2c::ErrorCode last_error_; }; using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 56d60f5b313..78bda0659da 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -49,8 +49,6 @@ void PCA9505Component::dump_config() { bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } -bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } - void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { const uint8_t bank = pin / 8; const uint8_t mask = 1 << (pin % 8); diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index da9c874078e..6c1ae3d6861 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -24,7 +24,6 @@ class PCA9505Component final : public gpio_expander::CachedGpioExpanderComponent protected: bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; bool read_bank_(uint8_t bank); @@ -35,8 +34,6 @@ class PCA9505Component final : public gpio_expander::CachedGpioExpanderComponent uint8_t config_[BANK_COUNT]{}; /// The output state per bank - 1 means HIGH, 0 means LOW uint8_t output_[BANK_COUNT]{}; - /// The input pin states per bank - 1 means HIGH, 0 means LOW - uint8_t input_[BANK_COUNT]{}; /// Which pins may pull INT low - 1 means masked, matching the chip uint8_t mask_[BANK_COUNT]{}; }; diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index db1d12beef3..96000398d0b 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -67,15 +67,7 @@ void PCA9554Component::dump_config() { LOG_I2C_DEVICE(this) } -bool PCA9554Component::digital_read_hw(uint8_t pin) { - // Read all pins from hardware into input_mask_ - return this->read_inputs_(); // Return true if I2C read succeeded, false on error -} - -bool PCA9554Component::digital_read_cache(uint8_t pin) { - // Return the cached pin state from input_mask_ - return this->input_mask_ & (1 << pin); -} +bool PCA9554Component::digital_read_hw(uint8_t pin) { return this->read_inputs_(); } void PCA9554Component::digital_write_hw(uint8_t pin, bool value) { assign_bits(this->output_mask_, 1 << pin, value); @@ -113,7 +105,7 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { } bool PCA9554Component::read_inputs_() { - uint8_t inputs[2]; + uint8_t inputs[2]{}; // the second byte stays 0 on the 8-pin parts if (this->is_failed()) { ESP_LOGD(TAG, "Device marked failed"); @@ -125,10 +117,7 @@ bool PCA9554Component::read_inputs_() { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) err); return false; } - this->input_mask_ = inputs[0]; - if (this->reg_width_ == 2) { - this->input_mask_ |= inputs[1] << 8; - } + this->input_[0] = encode_value_le(inputs); #ifdef USE_PCA9554_PCAL // The bus answers again, so retry the settings a failed write left off the chip this->retry_pcal_shadows_(); diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 77ba6399c13..8fb73c300b8 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -50,7 +50,6 @@ class PCA9554Component final : public gpio_expander::CachedGpioExpanderComponent // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; #ifdef USE_PCA9554_PCAL @@ -61,8 +60,6 @@ class PCA9554Component final : public gpio_expander::CachedGpioExpanderComponent uint16_t config_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state of the actual input pins - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; #ifdef USE_PCA9554_PCAL // Shadows of the PCAL registers. setup() writes them, so every part starts with the pulls disconnected // whatever its power-on state is (the PCAL9555 powers up with pull-ups enabled). diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 929ef5498e8..a5e16126991 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -17,7 +17,7 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); } else { - this->output_mask_ = this->input_mask_; + this->output_mask_ = this->input_[0]; this->mode_mask_ = ~this->configured_input_pins_; } @@ -32,12 +32,7 @@ void PCF8574Component::dump_config() { LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) } -bool PCF8574Component::digital_read_hw(uint8_t pin) { - // Read all pins from hardware into input_mask_ - return this->read_gpio_(); // Return true if I2C read succeeded, false on error -} - -bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +bool PCF8574Component::digital_read_hw(uint8_t pin) { return this->read_gpio_(); } void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { assign_bits(this->output_mask_, 1 << pin, value); @@ -61,7 +56,7 @@ bool PCF8574Component::read_gpio_() { uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. if (!this->status_comm_result(this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1))) return false; - this->input_mask_ = encode_value_le(data); + this->input_[0] = encode_value_le(data); return true; } bool PCF8574Component::write_gpio_() { diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ae2e3445fe9..6eed647f8fd 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -28,7 +28,6 @@ class PCF8574Component final : public gpio_expander::CachedGpioExpanderComponent protected: bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; bool read_gpio_(); @@ -38,8 +37,6 @@ class PCF8574Component final : public gpio_expander::CachedGpioExpanderComponent uint16_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins // (including undeclared pins) are treated as outputs seeded from their observed levels. uint16_t configured_input_pins_{0x00}; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index f5dc4c31cfa..f5664fab466 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -96,7 +96,7 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data), LOG_STR("Failed to read GPIO state"))) return false; - this->input_mask_ = data; + this->input_[0] = data; return true; } @@ -149,8 +149,6 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { return true; } -bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->input_mask_ & (1 << pin)); } - } // namespace esphome::pi4ioe5v6408 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pi4ioe5v6408::PI4IOE5V6408Component, "%u via PI4IOE5V6408") diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 9e0ffc02f84..f166321fd28 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -22,15 +22,12 @@ class PI4IOE5V6408Component final : public gpio_expander::CachedGpioExpanderComp protected: bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; /// Mask for the pin mode - 1 means output, 0 means input uint8_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_mask_{0x00}; - /// The state read in digital_read_hw - 1 means HIGH, 0 means LOW - uint8_t input_mask_{0x00}; /// The mask to write as input buffer state - 1 means enabled, 0 means disabled uint8_t pull_enable_mask_{0x00}; /// The mask to write as pullup state - 1 means pullup, 0 means pulldown diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 99d0058e7f6..1c27126a2cf 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -51,10 +51,8 @@ void Pixoo::setup() { // packet, so the LED board completes its final DMA block. this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; - if (!this->buffer_.init(this->data_size_)) { - this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + if (!this->setup_check(this->buffer_.init(this->data_size_), LOG_STR("Failed to allocate draw buffer"))) return; - } // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 8af15f97a00..d142d290add 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,9 +1,9 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable import dataclasses from dataclasses import dataclass import logging from pathlib import Path -from typing import Any +from typing import Any, NamedTuple from esphome import automation import esphome.codegen as cg @@ -56,7 +56,14 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority +from esphome.core import ( + CORE, + ID, + CoroPriority, + Lambda, + coroutine, + coroutine_with_priority, +) from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, SafeExpType, TemplateArgsType @@ -430,8 +437,18 @@ def register_protocol_action( call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", id_key=CONF_TRANSMITTER_ID, ) + _register_apply_transmit(name, action, validator, builder) - async def protocol_builder( + +def _register_apply_transmit( + name: str, + action: str, + validator: Any, + builder: Callable[..., Awaitable[MockObj]], +) -> None: + """Register ``builder`` for ``action`` as a core ``ApplyAction`` that keeps the protocol's source.""" + + async def transmit_builder( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, @@ -442,7 +459,70 @@ def register_protocol_action( automation.register_action( action, automation.ApplyAction, validator, synchronous=True - )(protocol_builder) + )(transmit_builder) + + +# The transmit data the statements of a ``_register_transmit_action`` encoder fill +_CALL_DATA = "esphome__call.get_data()" + + +async def _transmit_value( + value: Any, + args: TemplateArgsType, + type_: SafeExpType, + const_fn: Callable[[Any], Any] = cg.safe_exp, +) -> str: + """Argument text for one templatable transmit value. + + A lambda keeps the trigger's own argument types and is always called, so its body may + modify its by-value arguments. + """ + if isinstance(value, Lambda): + lamb = await cg.process_lambda(value, args, return_type=type_) + return str(automation.immediate_lambda_call(lamb)) + return str(const_fn(value)) + + +def _register_transmit_action( + name: str, + schema: cv.Schema, + encode: Callable[[ConfigType, TemplateArgsType], Awaitable[list[str]]], +) -> None: + """Register ``transmit_`` as inline statements, with no C++ action class. + + ``encode(config, args)`` returns the statements that fill ``_CALL_DATA``; + they run before ``repeat`` is read and the call is performed, in the order the old + ``RemoteTransmitterActionBase::play()`` used. + """ + action, _, validator = _transmit_action(name, schema, None) + + async def builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + transmitter = automation.parent_ref( + await cg.get_variable(config[CONF_TRANSMITTER_ID]) + ) + statements = [ + f"auto esphome__call = {transmitter.transmit()};", + *await encode(config, args), + ] + if (repeat := config.get(CONF_REPEAT)) is not None: + for key, setter in ( + (CONF_TIMES, "set_send_times"), + (CONF_WAIT_TIME, "set_send_wait"), + ): + value = await _transmit_value(repeat[key], args, cg.uint32) + statements.append(f"esphome__call.{setter}({value});") + statements.append("esphome__call.perform();") + return automation.apply_statements_action( + action_id, template_arg, args, statements + ) + + _register_apply_transmit(name, action, validator, builder) def declare_protocol(name): @@ -1568,7 +1648,8 @@ def validate_raw_alternating(value): return value -RawData, RawBinarySensor, RawTrigger, RawAction, RawDumper = declare_protocol("Raw") +RawData, RawBinarySensor, RawTrigger, _, RawDumper = declare_protocol("Raw") +encode_raw_timings = ns.encode_raw_timings CONF_CODE_STORAGE_ID = "code_storage_id" RAW_SCHEMA = cv.Schema( { @@ -1600,9 +1681,32 @@ def raw_dumper(var, config): pass -@register_action( +async def _raw_encode(config: ConfigType, args: TemplateArgsType) -> list[str]: + data = _CALL_DATA + code = config[CONF_CODE] + if config[CONF_CODE_STORAGE_ID].is_manual: + _LOGGER.warning( + "transmit_raw ignores code_storage_id '%s'; equal codes share one table", + config[CONF_CODE_STORAGE_ID], + ) + if isinstance(code, Lambda): + timings = await _transmit_value(code, args, cg.std_vector.template(cg.int32)) + statements = [f"{data}->set_data({timings});"] + else: + # Equal codes share one flash table + table = cg.shared_progmem_array( + "remote_base_raw_code", cg.int32, cg.ArrayInitializer(*code) + ) + statements = [f"{encode_raw_timings}({data}, ::{table}, {len(code)});"] + # transmit() already resets the frequency to 0 + if (frequency := config[CONF_CARRIER_FREQUENCY]) != 0: + frequency = await _transmit_value(frequency, args, cg.uint32) + statements.append(f"{data}->set_carrier_frequency({frequency});") + return statements + + +_register_transmit_action( "raw", - RawAction, RAW_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="0Hz"): cv.All( @@ -1610,18 +1714,8 @@ def raw_dumper(var, config): ), } ), + _raw_encode, ) -async def raw_action(var, config, args): - code_ = config[CONF_CODE] - if cg.is_template(code_): - template_ = await cg.templatable(code_, args, cg.std_vector.template(cg.int32)) - cg.add(var.set_code_template(template_)) - else: - code_ = config[CONF_CODE] - arr = cg.progmem_array(config[CONF_CODE_STORAGE_ID], code_) - cg.add(var.set_code_static(arr, len(code_))) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) # Drayton @@ -1979,15 +2073,11 @@ RC_SWITCH_TRANSMITTER = cv.Schema( rc_switch_protocol = ns.rc_switch_protocol rc_switch_protocol_copy = ns.rc_switch_protocol_copy RC_SWITCH_PROTOCOLS = ns.RC_SWITCH_PROTOCOLS +decode_binary_string = ns.decode_binary_string RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) RCSwitchDumper = ns.class_("RCSwitchDumper", RemoteReceiverDumperBase) -RCSwitchRawAction = ns.class_("RCSwitchRawAction", RemoteTransmitterActionBase) -RCSwitchTypeAAction = ns.class_("RCSwitchTypeAAction", RemoteTransmitterActionBase) -RCSwitchTypeBAction = ns.class_("RCSwitchTypeBAction", RemoteTransmitterActionBase) -RCSwitchTypeCAction = ns.class_("RCSwitchTypeCAction", RemoteTransmitterActionBase) -RCSwitchTypeDAction = ns.class_("RCSwitchTypeDAction", RemoteTransmitterActionBase) RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySensorBase) @@ -1997,19 +2087,6 @@ def rc_switch_raw_binary_sensor(var, config): cg.add(var.set_code(config[CONF_CODE])) -@register_action( - "rc_switch_raw", - RCSwitchRawAction, - RC_SWITCH_RAW_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_raw_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_code(await cg.templatable(config[CONF_CODE], args, cg.std_string))) - - @register_binary_sensor( "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) @@ -2018,23 +2095,6 @@ def rc_switch_type_a_binary_sensor(var, config): cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) -@register_action( - "rc_switch_type_a", - RCSwitchTypeAAction, - RC_SWITCH_TYPE_A_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_a_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) - cg.add( - var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.std_string)) - ) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) @@ -2045,21 +2105,6 @@ def rc_switch_type_b_binary_sensor(var, config): ) -@register_action( - "rc_switch_type_b", - RCSwitchTypeBAction, - RC_SWITCH_TYPE_B_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_b_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) @@ -2075,24 +2120,6 @@ def rc_switch_type_c_binary_sensor(var, config): ) -@register_action( - "rc_switch_type_c", - RCSwitchTypeCAction, - RC_SWITCH_TYPE_C_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_c_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add( - var.set_family(await cg.templatable(config[CONF_FAMILY], args, cg.std_string)) - ) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_d", RCSwitchRawReceiver, @@ -2103,19 +2130,141 @@ def rc_switch_type_d_binary_sensor(var, config): cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) -@register_action( - "rc_switch_type_d", - RCSwitchTypeDAction, - RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_d_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value +def _rc_switch_bits(code: str) -> int: + """``decode_binary_string()`` at build time: every character but ``0`` is a one.""" + return int("".join("0" if c == "0" else "1" for c in code), 2) + + +def _rc_switch_letter_index(value: str) -> int: + """A family or group letter as its index, as the C++ ``tolower(value[0]) - 'a'``.""" + return ord(value[0].lower()) - ord("a") + + +class _RCSwitchArg(NamedTuple): + """One argument of an ``RCSwitchBase::type_*_code()`` call, read from config.""" + + key: str + # What the user lambda returns, and how its value becomes the argument + lambda_type: SafeExpType = cg.uint8 + convert: str = "{}" + const_fn: Callable[[Any], Any] = cg.safe_exp + + +def _rc_switch_binary(key: str) -> _RCSwitchArg: + return _RCSwitchArg( + key, cg.std_string, f"{decode_binary_string}({{}})", _rc_switch_bits ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) + + +def _rc_switch_letter(key: str) -> _RCSwitchArg: + return _RCSwitchArg( + key, + cg.std_string, + "static_cast(tolower({}[0]) - 'a')", + _rc_switch_letter_index, + ) + + +_RC_SWITCH_STATE = _RCSwitchArg(CONF_STATE, cg.bool_) + + +async def _rc_switch_send( + config: ConfigType, args: TemplateArgsType, code: str, nbits: str +) -> str: + """The statement that sends ``code``; the protocol is read last, as before.""" + proto = await _transmit_value( + config[CONF_PROTOCOL], args, RCSwitchBase, rc_switch_protocol_value + ) + return f"{proto}.transmit({_CALL_DATA}, {code}, {nbits});" + + +async def _rc_switch_raw_encode( + config: ConfigType, args: TemplateArgsType +) -> list[str]: + code = config[CONF_CODE] + if isinstance(code, Lambda): + value = await _transmit_value(code, args, cg.std_string) + statements = [f"const std::string esphome__code = {value};"] + bits, nbits = f"{decode_binary_string}(esphome__code)", "esphome__code.size()" + else: + statements = [] + bits, nbits = str(cg.safe_exp(_rc_switch_bits(code))), str(len(code)) + return [*statements, await _rc_switch_send(config, args, bits, nbits)] + + +def _rc_switch_type_encode( + type_code: str, *fields: _RCSwitchArg +) -> Callable[[ConfigType, TemplateArgsType], Awaitable[list[str]]]: + """Statements running ``RCSwitchBase::`` on the fields, in order, then sending.""" + + async def encode(config: ConfigType, args: TemplateArgsType) -> list[str]: + statements: list[str] = [] + values: list[str] = [] + for field in fields: + value = config[field.key] + arg = await _transmit_value(value, args, field.lambda_type, field.const_fn) + if isinstance(value, Lambda): + # A local per lambda keeps the old evaluation order + local = f"esphome__{field.key}" + statements.append(f"const auto {local} = {field.convert.format(arg)};") + arg = local + values.append(arg) + return [ + *statements, + "uint64_t esphome__code;", + "uint8_t esphome__nbits;", + f"{RCSwitchBase}::{type_code}({', '.join(values)}, &esphome__code, &esphome__nbits);", + await _rc_switch_send(config, args, "esphome__code", "esphome__nbits"), + ] + + return encode + + +for _name, _schema, _encode in ( + ("rc_switch_raw", RC_SWITCH_RAW_SCHEMA, _rc_switch_raw_encode), + ( + "rc_switch_type_a", + RC_SWITCH_TYPE_A_SCHEMA, + _rc_switch_type_encode( + "type_a_code", + _rc_switch_binary(CONF_GROUP), + _rc_switch_binary(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_b", + RC_SWITCH_TYPE_B_SCHEMA, + _rc_switch_type_encode( + "type_b_code", + _RCSwitchArg(CONF_ADDRESS), + _RCSwitchArg(CONF_CHANNEL), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_c", + RC_SWITCH_TYPE_C_SCHEMA, + _rc_switch_type_encode( + "type_c_code", + _rc_switch_letter(CONF_FAMILY), + _RCSwitchArg(CONF_GROUP), + _RCSwitchArg(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_d", + RC_SWITCH_TYPE_D_SCHEMA, + _rc_switch_type_encode( + "type_d_code", + _rc_switch_letter(CONF_GROUP), + _RCSwitchArg(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), +): + _register_transmit_action(_name, _schema.extend(RC_SWITCH_TRANSMITTER), _encode) @register_trigger("rc_switch", RCSwitchTrigger, RCSwitchData) diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index affafa694ca..ef5d4fd66ad 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -13,7 +13,7 @@ constexpr uint64_t HEADER = 0b011001001100010uL; // 15 bits constexpr size_t HEADER_SIZE = 15; constexpr size_t CODE_SIZE = 17; -void GoboxProtocol::dump_timings_(const RawTimings &timings) const { +void GoboxProtocol::dump_timings_(RawTimingsView timings) const { ESP_LOGD(TAG, "Gobox: size=%u", timings.size()); for (int32_t timing : timings) { ESP_LOGD(TAG, "Gobox: timing=%ld", (long) timing); diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 55e8137b752..516d2d3ddb8 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -28,7 +28,7 @@ enum { class GoboxProtocol : public RemoteProtocol { private: - void dump_timings_(const RawTimings &timings) const; + void dump_timings_(RawTimingsView timings) const; public: void encode(RemoteTransmitData *dst, const GoboxData &data); diff --git a/esphome/components/remote_base/pronto_protocol.cpp b/esphome/components/remote_base/pronto_protocol.cpp index e47ba892917..d8808c42d98 100644 --- a/esphome/components/remote_base/pronto_protocol.cpp +++ b/esphome/components/remote_base/pronto_protocol.cpp @@ -190,7 +190,7 @@ std::string ProntoProtocol::dump_duration_(uint32_t duration, uint16_t timebase, return dump_number_((duration + timebase / 2) / timebase, end); } -std::string ProntoProtocol::compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase) { +std::string ProntoProtocol::compensate_and_dump_sequence_(RawTimingsView data, uint16_t timebase) { std::string out; for (int32_t t_length : data) { @@ -211,7 +211,7 @@ optional ProntoProtocol::decode(RemoteReceiveData src) { ProntoData out; uint16_t frequency = 38000U; - auto &data = src.get_raw_data(); + auto data = src.get_raw_data(); std::string prontodata; prontodata += dump_number_(frequency > 0 ? LEARNED_TOKEN : LEARNED_NON_MODULATED_TOKEN); diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index 4119b87f286..a03ad574970 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -27,7 +27,7 @@ class ProntoProtocol : public RemoteProtocol { std::string dump_digit_(uint8_t x); std::string dump_number_(uint16_t number, bool end = false); std::string dump_duration_(uint32_t duration, uint16_t timebase, bool end = false); - std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); + std::string compensate_and_dump_sequence_(RawTimingsView data, uint16_t timebase); public: void encode(RemoteTransmitData *dst, const ProntoData &data); diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 834ff1dddf7..06d5459a3dd 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -34,46 +34,31 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { class RawTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { - this->trigger(src.get_raw_data()); + if (const RawTimings *timings = src.get_raw_vector()) { + this->trigger(*timings); + return false; + } + // A receiver dispatching from its own buffer: x is a vector, so copy the frame into one kept here + RawTimingsView data = src.get_raw_data(); + this->timings_.assign(data.begin(), data.end()); + this->trigger(this->timings_); return false; } + + RawTimings timings_; }; -template class RawAction : public RemoteTransmitterActionBase { - public: - void set_code_template(RawTimings (*func)(Ts...)) { - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_code_static(const int32_t *code, size_t len) { - this->code_.data = code; - this->len_ = len; // Length >= 0 indicates static mode - } - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void encode(RemoteTransmitData *dst, Ts... x) override { - if (this->len_ >= 0) { - for (size_t i = 0; i < static_cast(this->len_); i++) { - auto val = this->code_.data[i]; - if (val < 0) { - dst->space(static_cast(-val)); - } else { - dst->mark(static_cast(val)); - } - } +/// Appends constant timings, marks positive and spaces negative; the table may be in flash +inline void encode_raw_timings(RemoteTransmitData *dst, const int32_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + auto val = data[i]; + if (val < 0) { + dst->space(static_cast(-val)); } else { - dst->set_data(this->code_.func(x...)); + dst->mark(static_cast(val)); } - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - RawTimings (*func)(Ts...); - const int32_t *data; - } code_; -}; +} class RawDumper : public RemoteReceiverDumperBase { public: diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index a102da3ff67..25c35f5ba03 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -100,112 +100,6 @@ uint64_t decode_binary_string(const std::string &data); uint64_t decode_binary_string_mask(const std::string &data); -template class RCSwitchRawAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto code = this->code_.value(x...); - uint64_t the_code = decode_binary_string(code); - uint8_t nbits = code.size(); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, the_code, nbits); - } -}; - -template class RCSwitchTypeAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, group); - TEMPLATABLE_VALUE(std::string, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - uint8_t u_group = decode_binary_string(group); - uint8_t u_device = decode_binary_string(device); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_a_code(u_group, u_device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - -template class RCSwitchTypeBAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(uint8_t, address); - TEMPLATABLE_VALUE(uint8_t, channel); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto address = this->address_.value(x...); - auto channel = this->channel_.value(x...); - auto state = this->state_.value(x...); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_b_code(address, channel, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - -template class RCSwitchTypeCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, family); - TEMPLATABLE_VALUE(uint8_t, group); - TEMPLATABLE_VALUE(uint8_t, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto family = this->family_.value(x...); - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - - auto u_family = static_cast(tolower(family[0]) - 'a'); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_c_code(u_family, group, device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; -template class RCSwitchTypeDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, group); - TEMPLATABLE_VALUE(uint8_t, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - - auto u_group = static_cast(tolower(group[0]) - 'a'); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_d_code(u_group, device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index ddd992266fe..cc9140a2200 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -133,10 +133,10 @@ void RemoteReceiverBase::warn_extra_secondaries_() { } #endif -void RemoteReceiverBase::call_listeners_dumpers_() { +void RemoteReceiverBase::dispatch_(const RemoteReceiveData &src) { #ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) - listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + listener->on_receive(src); #endif #ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; @@ -145,12 +145,12 @@ void RemoteReceiverBase::call_listeners_dumpers_() { if (dumper->is_secondary()) { if (secondary == nullptr) secondary = dumper; - } else if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) { + } else if (dumper->dump(src)) { success = true; } } if (!success && secondary != nullptr) - secondary->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + secondary->dump(src); #endif } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 8cc06091cb6..71b733e0537 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -18,6 +19,8 @@ enum ToleranceMode : uint8_t { }; using RawTimings = std::vector; +/// A received frame's timings, viewed in whatever buffer the receiver keeps them. +using RawTimingsView = std::span; enum class BitOrder : uint8_t { BIT_ORDER_LSB_FIRST, @@ -87,10 +90,15 @@ class RemoteTransmitData { class RemoteReceiveData { public: - explicit RemoteReceiveData(const RawTimings &data, uint32_t tolerance, ToleranceMode tolerance_mode) + /// The timings are not copied: they must outlive this object. + explicit RemoteReceiveData(RawTimingsView data, uint32_t tolerance, ToleranceMode tolerance_mode) : data_(data), index_(0), tolerance_(tolerance), tolerance_mode_(tolerance_mode) {} + explicit RemoteReceiveData(const RawTimings &data, uint32_t tolerance, ToleranceMode tolerance_mode) + : data_(data), vector_(&data), index_(0), tolerance_(tolerance), tolerance_mode_(tolerance_mode) {} - const RawTimings &get_raw_data() const { return this->data_; } + RawTimingsView get_raw_data() const { return this->data_; } + /// The timings as a vector when they were given as one, else nullptr. + const RawTimings *get_raw_vector() const { return this->vector_; } uint32_t get_index() const { return index_; } int32_t operator[](uint32_t index) const { return this->data_[index]; } int32_t size() const { return this->data_.size(); } @@ -178,7 +186,8 @@ class RemoteReceiveData { return 0; } - const RawTimings &data_; + RawTimingsView data_; + const RawTimings *vector_{nullptr}; uint32_t index_; uint32_t tolerance_; ToleranceMode tolerance_mode_; @@ -364,7 +373,17 @@ class RemoteReceiverBase : public RemoteComponentBase { #ifdef REMOTE_BASE_DUMPER_COUNT void warn_extra_secondaries_(); #endif - void call_listeners_dumpers_(); + void call_listeners_dumpers_() { + this->dispatch_(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + } + /// Dispatch a frame held in the receiver's own buffer, without copying it into temp_. + void call_listeners_dumpers_(RawTimingsView data) { + this->dispatch_(RemoteReceiveData(data, this->tolerance_, this->tolerance_mode_)); + } + /// A vector goes through call_listeners_dumpers_() (temp_), which on_raw passes on without a copy. + void call_listeners_dumpers_(const RawTimings &data) = delete; + /// Each listener and dumper gets its own copy of src, starting at index 0. + void dispatch_(const RemoteReceiveData &src); #ifdef REMOTE_BASE_LISTENER_COUNT StaticVector listeners_; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index e28b4913fee..0baeb9833c7 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -394,6 +394,7 @@ async def to_code(config: ConfigType) -> None: ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 0741cc24e39..c94d3c1826b 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -131,6 +132,31 @@ void crash_handler_log() { } // namespace esphome::rp2 +namespace esphome { + +static_assert(MAX_BACKTRACE + 1 <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !rp2::crash_handler_has_data()) + return false; + out = {}; + // Never survives into another build + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + out.reason = CrashReason::CRASH_REASON_EXCEPTION; + out.pc = rp2::s_crash_data.pc; + auto &core = out.cores[out.core_count++]; + core.addresses[core.count++] = rp2::s_crash_data.lr; + for (uint8_t i = 0; i < rp2::s_crash_data.backtrace_count; i++) + core.addresses[core.count++] = rp2::s_crash_data.backtrace[i]; + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. // On Cortex-M0+, the CPU pushes {R0,R1,R2,R3,R12,LR,PC,xPSR} onto the diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index c7d8fa713b1..96933662920 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -19,7 +19,7 @@ ESPHOME_LOG_TAG(TAG, "rp2040_pio_led_strip"); // DMA interrupt service routine void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { uint32_t channel = dma_hw->ints0; - for (uint dma_chan = 0; dma_chan < 12; ++dma_chan) { + for (uint dma_chan = 0; dma_chan < NUM_DMA_CHANNELS; ++dma_chan) { if (RP2040PIOLEDStripLightOutput::dma_chan_active[dma_chan] && (channel & (1u << dma_chan))) { dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[dma_chan]); // Handle the interrupt @@ -29,55 +29,57 @@ void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { void RP2040PIOLEDStripLightOutput::setup() { RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } // Initialize the PIO program // Select PIO instance to use (0 or 1) - if (this->pio_ == nullptr) { - this->mark_failed(LOG_STR("Failed to claim PIO instance")); + if (!this->setup_check(this->pio_ != nullptr, LOG_STR("Failed to claim PIO instance"))) return; - } - // if there are multiple strips, we can reuse the same PIO program and save space - // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO + // There are only 4 state machines on each PIO, so at most 4 strips per PIO + auto &loaded = RP2040PIOLEDStripLightOutput::loaded_programs[pio_get_index(this->pio_)]; + if (!this->setup_check(loaded.strips < NUM_PIO_STATE_MACHINES, LOG_STR("Too many instances of PIO program"))) + return; + + // Strips with the same program on the same PIO share one copy of it in instruction memory uint offset = 0; - - if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { - this->mark_failed(LOG_STR("Too many instances of PIO program")); - return; - } - // keep track of how many instances of the PIO program are running on each PIO - RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; - - // if there are multiple strips of the same chipset, we can reuse the same PIO program and save space - if (RP2040PIOLEDStripLightOutput::conf_count[this->chipset_]) { - offset = RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_]; - } else { - // Load the assembled program into the PIO and get its location in the PIO's instruction memory and save it - offset = pio_add_program(this->pio_, this->program_); - RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_] = offset; - RP2040PIOLEDStripLightOutput::conf_count[this->chipset_] = true; + for (uint8_t i = 0; i < NUM_PIO_STATE_MACHINES; i++) { + if (loaded.program[i] == nullptr) { + // The SDK panics when instruction memory is full + if (!this->setup_check(pio_can_add_program(this->pio_, this->program_), + LOG_STR("No PIO instruction memory left"))) + return; + offset = pio_add_program(this->pio_, this->program_); + loaded.program[i] = this->program_; + loaded.offset[i] = offset; + break; + } + if (loaded.program[i] == this->program_) { + offset = loaded.offset[i]; + break; + } } + loaded.strips++; // Configure the state machine's PIO, and start it - this->sm_ = pio_claim_unused_sm(this->pio_, true); - if (this->sm_ < 0) { - // in theory this code should never be reached + int sm = pio_claim_unused_sm(this->pio_, false); + if (sm < 0) { this->mark_failed(LOG_STR("Failed to claim PIO state machine")); return; } + this->sm_ = sm; - // Initalize the DMA channel (Note: There are 12 DMA channels and 8 state machines so we won't run out) - - this->dma_chan_ = dma_claim_unused_channel(true); - if (this->dma_chan_ < 0) { + // Other code may already hold every DMA channel + int dma_chan = dma_claim_unused_channel(false); + if (dma_chan < 0) { + pio_sm_unclaim(this->pio_, this->sm_); this->mark_failed(LOG_STR("Failed to claim DMA channel")); return; } + this->dma_chan_ = dma_chan; // Mark the DMA channel as active RP2040PIOLEDStripLightOutput::dma_chan_active[this->dma_chan_] = true; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ff291da0048..8f76a908ce6 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -15,19 +15,9 @@ #include #include #include -#include namespace esphome::rp2040_pio_led_strip { -enum Chipset : uint8_t { - CHIPSET_WS2812, - CHIPSET_WS2812B, - CHIPSET_SK6812, - CHIPSET_SM16703, - CHIPSET_APA102, - CHIPSET_CUSTOM = 0xFF, -}; - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -50,7 +40,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_program(const pio_program_t *program) { this->program_ = program; } void set_init_function(init_fn init) { this->init_ = init; } - void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void dump_config() override; @@ -70,19 +59,21 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint dma_chan_; dma_channel_config dma_config_; - Chipset chipset_{CHIPSET_CUSTOM}; - float max_refresh_rate_; const pio_program_t *program_; init_fn init_; private: - inline static int num_instance[2]; - inline static std::map conf_count; - inline static std::map chipset_offsets; - inline static bool dma_chan_active[12]; - inline static struct semaphore dma_write_complete_sem[12]; + // Programs loaded into each PIO; strips with the same timings share one program pointer + struct LoadedPrograms { + const pio_program_t *program[NUM_PIO_STATE_MACHINES]; + uint8_t offset[NUM_PIO_STATE_MACHINES]; + uint8_t strips; + }; + inline static LoadedPrograms loaded_programs[2]; + inline static bool dma_chan_active[NUM_DMA_CHANNELS]; + inline static struct semaphore dma_write_complete_sem[NUM_DMA_CHANNELS]; }; } // namespace esphome::rp2040_pio_led_strip diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 53a9d438292..e75d5ff616b 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -7,7 +7,6 @@ from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, - CONF_ID, CONF_IS_RGBW, CONF_NUM_LEDS, CONF_OUTPUT_ID, @@ -15,7 +14,6 @@ from esphome.const import ( CONF_RGB_ORDER, ) from esphome.types import ConfigType -from esphome.util import _LOGGER def get_nops(timing: float) -> list[float | str]: @@ -39,7 +37,11 @@ def get_nops(timing: float) -> list[float | str]: return nops -def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: +# The state machine clock every timing is counted in +PIO_CLOCK_HZ = 57_500_000 + + +def generate_assembly_code(name: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. """ @@ -62,21 +64,19 @@ def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> s % c-sdk {{ #include "hardware/clocks.h" -static inline void rp2040_pio_led_strip_driver_{id}_init(PIO pio, uint sm, uint offset, uint pin, float freq) {{ +static inline void rp2040_pio_led_strip_driver_{name}_init(PIO pio, uint sm, uint offset, uint pin, float freq) {{ pio_gpio_init(pio, pin); pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); - pio_sm_config c = rp2040_pio_led_strip_{id}_program_get_default_config(offset); + pio_sm_config c = rp2040_pio_led_strip_{name}_program_get_default_config(offset); sm_config_set_set_pins(&c, pin, 1); sm_config_set_out_shift(&c, false, true, 8); sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); - // target frequency is 57.5MHz - long clk = clock_get_hz(clk_sys); - long target_freq = 57500000; - int n = 2; - int f = round(((clk / target_freq) - n ) * 256); - sm_config_set_clkdiv_int_frac(&c, n, f); + // The divider is 8.8 fixed point, rounded to the nearest step + uint32_t clk = clock_get_hz(clk_sys); + uint32_t div = (uint32_t) (((uint64_t) clk * 256 + {PIO_CLOCK_HZ // 2}) / {PIO_CLOCK_HZ}); + sm_config_set_clkdiv_int_frac(&c, div >> 8, div & 0xFF); pio_sm_init(pio, sm, offset, &c); @@ -84,7 +84,7 @@ static inline void rp2040_pio_led_strip_driver_{id}_init(PIO pio, uint sm, uint }} %}}""" - assembly_template = f""".program rp2040_pio_led_strip_{id} + assembly_template = f""".program rp2040_pio_led_strip_{name} .wrap_target awaiting_data: @@ -126,8 +126,7 @@ writezero: def time_to_cycles(time_us: float) -> int: - cycles_per_us = 57.5 - return round(float(time_us) * cycles_per_us) + return round(float(time_us) * (PIO_CLOCK_HZ / 1_000_000)) CONF_PIO = "pio" @@ -141,10 +140,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -Chipset = rp2040_pio_led_strip_ns.enum("Chipset") - -CHIPSETS = Chipset.options("WS2812", "WS2812B", "SK6812", "SM16703", prefix="CHIPSET_") - @dataclass class LEDStripTimings: @@ -190,7 +185,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), - cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), + cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSET_TIMINGS, upper=True), cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -218,7 +213,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - id = config[CONF_ID].id await light.register_light(var, config) await cg.register_component(var, config) @@ -230,40 +224,28 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_pio(config[CONF_PIO])) - cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) - cg.add( - var.set_init_function( - cg.RawExpression(f"rp2040_pio_led_strip_driver_{id}_init") - ) - ) - - key = f"led_strip_{id}" if chipset := config.get(CONF_CHIPSET): - cg.add(var.set_chipset(chipset)) - _LOGGER.info("Generating PIO assembly code") - rp2.add_pio_file( - __name__, - key, - generate_assembly_code( - id, - CHIPSET_TIMINGS[chipset].T0H, - CHIPSET_TIMINGS[chipset].T0L, - CHIPSET_TIMINGS[chipset].T1H, - CHIPSET_TIMINGS[chipset].T1L, - ), - ) + timings = CHIPSET_TIMINGS[chipset] else: - cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) - _LOGGER.info("Generating custom PIO assembly code") - rp2.add_pio_file( - __name__, - key, - generate_assembly_code( - id, - time_to_cycles(config[CONF_BIT0_HIGH]), - time_to_cycles(config[CONF_BIT0_LOW]), - time_to_cycles(config[CONF_BIT1_HIGH]), - time_to_cycles(config[CONF_BIT1_LOW]), - ), + timings = LEDStripTimings( + time_to_cycles(config[CONF_BIT0_HIGH]), + time_to_cycles(config[CONF_BIT0_LOW]), + time_to_cycles(config[CONF_BIT1_HIGH]), + time_to_cycles(config[CONF_BIT1_LOW]), ) + # Strips with the same timings share one program, so the C++ side loads it once per PIO + name = f"{timings.T0H}_{timings.T0L}_{timings.T1H}_{timings.T1L}" + cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{name}_program"))) + cg.add( + var.set_init_function( + cg.RawExpression(f"rp2040_pio_led_strip_driver_{name}_init") + ) + ) + rp2.add_pio_file( + __name__, + f"led_strip_{name}", + generate_assembly_code( + name, timings.T0H, timings.T0L, timings.T1H, timings.T1L + ), + ) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 3cbc3ab7f90..11a00df96fa 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -427,6 +427,7 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: return; } auto *client = this->usb_channel_->get_parent(); + const uint8_t interface_number = this->usb_channel_->get_interface_number(); msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED; if (!client->get_device_info(scratch)) { msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR; @@ -435,9 +436,10 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.vendor_id = scratch.vendor_id; msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; - msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_number = interface_number; msg.usb.interface_class = this->usb_channel_->get_interface_class(); msg.usb.port_number = this->usb_channel_->get_port_number(); + msg.usb.interface_description = StringRef(this->usb_channel_->get_interface_description()); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index abe72becb8c..7fdad74deaf 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -107,10 +107,8 @@ void SGP4xComponent::setup() { void SGP4xComponent::self_test_() { ESP_LOGD(TAG, "Starting self-test"); - if (!this->write_command(SGP4X_CMD_SELF_TEST)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->write_command(SGP4X_CMD_SELF_TEST))) return; - } this->set_timeout(SGP4X_SELF_TEST_TIME, [this]() { uint16_t reply = 0; diff --git a/esphome/components/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index 5d05d2c8da9..d98b6b9fa0d 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -34,10 +34,9 @@ void SHT3XDComponent::setup() { } this->serial_number_ = (uint32_t(raw_serial_number[0]) << 16) | uint32_t(raw_serial_number[1]); - if (!this->write_command(this->heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE)) { - this->mark_failed(LOG_STR("Failed to set heater mode")); - return; - } + this->setup_check( + this->write_command(this->heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE), + LOG_STR("Failed to set heater mode")); } void SHT3XDComponent::dump_config() { diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 2ff194e0b26..76de3cae4a8 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -37,10 +37,8 @@ void SHT4XComponent::read_serial_number_() { void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); - if (err != i2c::ERROR_OK) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(err == i2c::ERROR_OK)) return; - } this->read_serial_number_(); diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index 5addda669df..10a0b771df5 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -98,9 +98,10 @@ void SM2135::loop() { this->write_byte_(current_mask_); if (this->separate_modes_) { - if (this->update_channel_ == 3 || this->update_channel_ == 4) { - // No color so must be Cold/Warm - + const bool color_on = this->pwm_amounts_[0] || this->pwm_amounts_[1] || this->pwm_amounts_[2]; + const bool white_on = this->pwm_amounts_[3] || this->pwm_amounts_[4]; + // Both groups only hold a value without color_interlock; the last updated channel then decides + if (!color_on || (white_on && this->update_channel_ >= 3)) { this->write_byte_(SM2135_CW); this->sm2135_stop_(); delay(1); @@ -109,8 +110,6 @@ void SM2135::loop() { this->write_byte_(this->pwm_amounts_[3]); this->write_byte_(this->pwm_amounts_[4]); } else { - // Color - this->write_byte_(SM2135_RGB); this->write_byte_(this->pwm_amounts_[0]); this->write_byte_(this->pwm_amounts_[1]); diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index ce047115d57..56be75ef69e 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -63,7 +63,7 @@ class SM2135 final : public Component { SM2135Current rgb_current_; SM2135Current cw_current_; bool separate_modes_; - uint8_t update_channel_; + uint8_t update_channel_{0}; std::vector pwm_amounts_; bool update_{true}; }; diff --git a/esphome/components/spi_led_strip/spi_led_strip.cpp b/esphome/components/spi_led_strip/spi_led_strip.cpp index 1a1d749dd28..e5bb13265b7 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.cpp +++ b/esphome/components/spi_led_strip/spi_led_strip.cpp @@ -10,10 +10,8 @@ SpiLedStrip::SpiLedStrip(uint16_t num_leds) : buffer_size_(num_leds * 4 + BUFFER void SpiLedStrip::setup() { RAMAllocator allocator; uint8_t *buf = allocator.allocate(this->buffer_size_ + this->num_leds_); - if (buf == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(buf != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } memset(buf, 0xFF, this->buffer_size_); memset(buf, 0, 4); this->internal_accessor_.color_data = buf + COLOR_DATA_OFFSET; diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 1c9474bdb7b..ca24b53b603 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -14,10 +14,8 @@ ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); - if (!this->sock_) { - this->mark_failed(LOG_STR("Could not create socket")); + if (!this->setup_check(this->sock_ != nullptr, LOG_STR("Could not create socket"))) return; - } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); diff --git a/esphome/components/store_yaml/__init__.py b/esphome/components/store_yaml/__init__.py new file mode 100644 index 00000000000..4e305eac710 --- /dev/null +++ b/esphome/components/store_yaml/__init__.py @@ -0,0 +1,723 @@ +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +import logging +import os +from pathlib import Path +import re +import struct + +import yaml + +from esphome import yaml_util +import esphome.codegen as cg +from esphome.components import noise, packages +from esphome.config_helpers import Extend, Remove +import esphome.config_validation as cv +from esphome.const import ( + CONF_API, + CONF_DEFAULTS, + CONF_ID, + CONF_PACKAGES, + CONF_SSID, + CONF_SUBSTITUTIONS, +) +from esphome.core import CORE, EsphomeError, Lambda +from esphome.expression import SUBSTITUTION_VARIABLE_PROG +import esphome.final_validate as fv +from esphome.git import URL_USERINFO_RE +from esphome.helpers import ensure_unique_string, list_starts_with, zstd_module +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@bdraco"] +DEPENDENCIES = ["api"] +DOMAIN = "store_yaml" + +CONF_INCLUDE_SECRETS = "include_secrets" +CONF_ALLOW_UNENCRYPTED = "allow_unencrypted" + +# 22 is the max and gives ~70-90% reduction on YAML. +ZSTD_LEVEL = 22 +# "EHY1" = ESPHome YAML, version 1. +ENVELOPE_MAGIC = b"EHY1" +SECRETS_SKELETON_HEADER = ( + "# Redacted by store_yaml. Fill in these values and the recovered\n" + "# config is ready to flash.\n" +) +UNCAPTURED_NOTE_PATH = "store_yaml_uncaptured.yaml" +_FIX = ( + "Reference them with `!secret`, or set `include_secrets: true` to embed " + "secrets deliberately." +) +# Substitution names to every value they may take in one file. +_Scope = dict[str, list[object]] +# An `!include`, its vars before any folding, and the config path of its site. +_Include = tuple[yaml_util.IncludeFile, dict[str, object], tuple[str, ...]] +# Stands in for an integer credential at its keys and definitions. +_INT_MARK = "\x00" +# A removed branch is never validated, so its credentials are known only by name. +_CREDENTIAL_WORDS = ( + "password", + "key", + "secret", + "token", + "psk", + "tlv", + "pairing_code", + "pin_code", +) +# Every key validated with cv.sensitive (a test keeps this complete) and the +# packet_transport `encryption: ` shorthand: credentials wherever they appear, +# also when a merge or override drops them before validation. SSIDs are broadcast, +# and a bare `key` also names keypad keys; both are checked where validation kept them. +_SENSITIVE_KEYS = frozenset( + {"password", "new_password", "private_key", "peer_preshared_key"} + | {"client_certificate_key", "network_key", "old_key", "pin_code", "pskc", "tlv"} + | {"static_pairing_code", "encryption", "bindkey", "decryption_key", "auth_key"} +) +# Keys under these name declared arguments, not values (`variables: {password: string}`). +_DECLARATIONS = frozenset({"variables", "parameters"}) +# cv.bind_key's normalised output: 32 uppercase hex digits. +_BIND_KEY_RE = re.compile(r"[0-9A-F]{32}") +# A quoted string or a number: a lambda without one cannot spell a credential. +_LITERAL_RE = re.compile(r"""["']|(? ConfigType: + """Require an API encryption key unless ``allow_unencrypted`` is set.""" + full = fv.full_config.get() + if noise.static_encryption_key(full.get(CONF_API, {})) is not None: + return config + if config.get(CONF_ALLOW_UNENCRYPTED): + _LOGGER.warning( + "store_yaml is enabled without API encryption; any client that can " + "reach the device on the network can pull the embedded YAML%s.", + " including the verbatim contents of secrets.yaml" + if config.get(CONF_INCLUDE_SECRETS) + else "", + ) + return config + raise cv.Invalid( + "store_yaml requires API encryption (`api.encryption.key`), or any client " + "that reaches the device can read the embedded YAML. Set " + "`store_yaml.allow_unencrypted: true` to accept that." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _gather_files( + discovered: yaml_util.DiscoveredYamlFiles, +) -> tuple[list[tuple[str, Path]], set[str]]: + """(envelope path, named path) per name a file was reached by, and the envelope + paths of secrets files.""" + if discovered.load_errors: + raise EsphomeError( + "store_yaml: could not load all configuration files: " + + "; ".join(discovered.load_errors) + ) + if discovered.unresolved: + _LOGGER.warning( + "store_yaml: %d !include path(s) use substitutions and cannot be " + "captured (%s); the embedded recovery data will not contain them", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) + + root = yaml_util.named_path(CORE.config_path).parent + entries: list[tuple[str, Path]] = [] + secret_rels: set[str] = set() + for path in discovered.files: + for alias in discovered.aliases.get(path, [path]): + # Keep ".." for files outside the root so same-named files don't collide. + try: + rel_str = alias.relative_to(root, walk_up=True).as_posix() + except ValueError as err: + raise EsphomeError( + f"store_yaml: cannot place {alias} in the recovery envelope; " + f"it does not share a root with {root}: {err}" + ) from err + if path in discovered.secrets: + secret_rels.add(rel_str) + entries.append((rel_str, alias)) + return entries, secret_rels + + +def _read_files_verbatim(entries: list[tuple[str, Path]]) -> list[tuple[str, bytes]]: + """Read each file's exact on-disk bytes (the `include_secrets: true` path).""" + files: list[tuple[str, bytes]] = [] + for rel, path in entries: + try: + files.append((rel, path.read_bytes())) + except OSError as err: + raise EsphomeError( + f"store_yaml: cannot read tracked YAML file {path}: {err}" + ) from err + return files + + +class _IncludeName(str): + """An `!include` file name: scanned for secrets, never taken for the value it loads.""" + + __slots__ = () + + +def _iter_nodes( + node: object, path: tuple[str, ...] = () +) -> Generator[tuple[tuple[str, ...], object, bool]]: + """Yield (config_path, value, is_key) for every key and scalar, unwrapping the + tagged wrappers the dumper writes as text.""" + if isinstance(node, dict): + for key, value in node.items(): + yield path, str(key), True + yield from _iter_nodes(value, (*path, str(key))) + elif isinstance(node, (list, tuple)): + for item in node: + yield from _iter_nodes(item, path) + elif isinstance(node, Lambda): + yield path, node, False + elif isinstance(node, (Extend, Remove)): + yield path, node.value, False + elif isinstance(node, yaml_util.IncludeFile): + yield path, _IncludeName(node.file), False + if isinstance(node.condition, str): + yield path, node.condition, False + if node.vars: + yield from _iter_nodes(node.vars, path) + elif isinstance(node, (str, int, float)) and not isinstance(node, bool): + yield path, node, False + + +def _str_keys(value: object) -> dict[str, object]: + """`value` as a mapping with string keys, or empty.""" + return {str(k): v for k, v in value.items()} if isinstance(value, dict) else {} + + +def _ends_with(full: tuple[str, ...], part: tuple[str, ...]) -> bool: + """`part` ends `full`, skipping segments validation inserted (`wifi.password` + to `wifi.networks.password`).""" + if full[-1] != part[-1]: + return False + rest = iter(reversed(full[:-1])) + return all(seg in rest for seg in reversed(part[:-1])) + + +def _merged(path: tuple[str, ...]) -> set[tuple[str, ...]]: + """`path` as validation sees it: every `packages[.]` prefix merges away.""" + if path and path[0] == CONF_PACKAGES: + return _merged(path[1:]) | _merged(path[2:]) + return {path} + + +def _defines_substitution(path: tuple[str, ...]) -> bool: + """`path` is a `substitutions:` entry, also inside an inline package.""" + return any(len(p) > 1 and p[0] == CONF_SUBSTITUTIONS for p in _merged(path)) + + +@dataclass +class _SensitiveValue: + secret_name: str + config_path: str # dotted path, for messages (never the value itself) + from_secret: bool # loaded through a real `!secret` + # Keys the value sits at, to tell its own occurrences from collisions. + sensitive_keys: set[str] + # Integers skip the substring scan; short PINs would match unrelated text. + is_int: bool = True + + +def _include_scopes( + includes: dict[str, list[_Include]], + trees: dict[str, object], + sources: dict[str, Path], +) -> tuple[dict[str, _Scope], dict[str, list[tuple[str, ...]]]]: + """Per file: every value a substitution name may take (per include: its vars, + else the file's `defaults:`, else the caller's chain, else substitutions) and + the config paths its content lands under.""" + substitutions: _Scope = {} + for tree in trees.values(): + for path, node, is_key in _iter_nodes(tree): + if not is_key and _defines_substitution(path): + substitutions.setdefault(path[-1], []).append(node) + defaults = { + rel: _str_keys(_str_keys(tree).get(CONF_DEFAULTS)) + for rel, tree in trees.items() + } + rels_by_path: dict[Path, list[str]] = {} + for rel, path in sources.items(): + rels_by_path.setdefault(path.resolve(), []).append(rel) + edges: dict[str, list[tuple[str, dict[str, object], tuple[str, ...]]]] = {} + for src, items in includes.items(): + for include, vars, site in items: + if include.has_unresolved_file() or not yaml_util.include_enabled(include): + continue + target = (include.parent_file.parent / include.file).resolve() + edges.setdefault(src, []).extend( + (rel, vars, site) for rel in rels_by_path.get(target, ()) + ) + # Each way a file is reached: its vars chain, built as the loader does, and + # the include-site prefix; a caller's chain flows into nested includes. + targeted = {rel for targets in edges.values() for rel, _, _ in targets} + invocations: dict[str, list[tuple[dict[str, object], tuple[str, ...]]]] = {} + pending = [(rel, defaults[rel], (), ()) for rel in trees if rel not in targeted] + while pending: + rel, chain, prefix, lineage = pending.pop() + if (chain, prefix) in invocations.setdefault(rel, []): + continue + invocations[rel].append((chain, prefix)) + pending.extend( + (dst, {**chain, **defaults[dst], **vars}, prefix + site, (*lineage, rel)) + for dst, vars, site in edges.get(rel, ()) + if dst not in lineage # an include cycle never ends otherwise + ) + scopes: dict[str, _Scope] = {} + prefixes: dict[str, list[tuple[str, ...]]] = {} + for rel in trees: + chains = invocations.get(rel) or [(defaults[rel], ())] + scope: _Scope = {} + for chain, _ in chains: + for name in {*substitutions, *chain}: + scope.setdefault(name, []).extend( + [chain[name]] if name in chain else substitutions[name] + ) + scopes[rel] = scope + prefixes[rel] = [prefix for _, prefix in chains] + return scopes, prefixes + + +def _check_sensitive_usage( + sensitive: dict[str, _SensitiveValue], + sensitive_paths: set[tuple[str, ...]], + trees: dict[str, object], + scopes: dict[str, _Scope], + prefixes: dict[str, list[tuple[str, ...]]], + removed: set[tuple[str, ...]], +) -> None: + """A sensitive value is a whole scalar (swapped) or fails, except a secret SSID + inside a name (warned); unverifiable credential locations and URL userinfo fail.""" + credential_keys = {sp[-1] for sp in sensitive_paths if sp} + + def verified(text: str) -> bool: + return not text or text in sensitive or yaml_util.is_secret(text) is not None + + def references_verified( + text: str, scope: _Scope, seen: frozenset = frozenset() + ) -> bool: + """Every reference names definitions that are verified or alias verified ones.""" + names = [m.strip("{}") for m in SUBSTITUTION_VARIABLE_PROG.findall(text)] + return bool(names) and all( + name in scope + and name not in seen + and all( + verified(str(v)) + or SUBSTITUTION_VARIABLE_PROG.fullmatch(str(v)) is not None + and references_verified(str(v), scope, seen | {name}) + for v in scope[name] + ) + for name in names + ) + + def credential_path(path: tuple[str, ...]) -> bool: + """Whether `path` lands where the validated config holds a credential.""" + if not path: + return False + merged = _merged(path) + if path[-1] in _SENSITIVE_KEYS and not _DECLARATIONS.intersection(path[-2:-1]): + return True + if any(p and any(list_starts_with(p, r) for r in removed) for p in merged): + return path[-1] in credential_keys or any( + word in path[-1] for word in _CREDENTIAL_WORDS + ) + return path[-1] in credential_keys and any( + # One segment says nothing about where an `!include_dir_*` file lands + len(p) > 1 and any(_ends_with(sp, p) for sp in sensitive_paths) + for p in merged + ) + + def shown(path: tuple[str, ...]) -> str: + """`path` for messages, credential segments masked.""" + return ".".join( + f"" if seg in sensitive else seg + for seg in path + ) + + failures: dict[str, list[str]] = { + "appear embedded inside larger values or as mapping keys, which redaction " + "does not replace": [], + "cannot be confirmed redacted: they sit at a credential key or location but " + "are not a known secret (for example a literal password, or one a merge, " + "override or `!remove` dropped before validation)": [], + } + embedded, unverified = failures.values() + for rel, tree in trees.items(): + scope = scopes[rel] + for path, node, is_key in _iter_nodes(tree): + text = str(node) + where = ( + f"a mapping key under {shown(path) or 'the document root'}" + if is_key + else shown(path) + ) + if verified(text): + # A whole scalar is swapped for its `!secret`; a key never is. + if is_key and (info := sensitive.get(text)) is not None: + embedded.append(f"{info.config_path} (as {where} in {rel})") + continue + if not is_key and any( + SUBSTITUTION_VARIABLE_PROG.sub("", userinfo) not in ("", ":") + or not references_verified(userinfo, scope) + for userinfo in URL_USERINFO_RE.findall(text) + ): + unverified.append(f"{where} in {rel} (a URL with credentials)") + elif ( + not is_key + and not isinstance(node, _IncludeName) + and any(credential_path(prefix + path) for prefix in prefixes[rel]) + and not ( + _LITERAL_RE.search(text) is None + if isinstance(node, Lambda) + else SUBSTITUTION_VARIABLE_PROG.fullmatch(text) is not None + and references_verified(text, scope) + ) + ): + unverified.append(f"{where} in {rel}") + continue + for value, other in sensitive.items(): + # A key the value is sensitive at (`password` for `word`) is structure. + if ( + other.is_int + or value not in text + or (is_key and text in other.sensitive_keys) + ): + continue + if other.from_secret and other.sensitive_keys <= {CONF_SSID}: + # A broadcast SSID inside a name is common; warn without the value. + _LOGGER.warning( + "store_yaml: !secret %s is not redacted inside %s in %s", + other.secret_name, + where, + rel, + ) + else: + embedded.append(f"{other.config_path} (inside {where} in {rel})") + for why, hits in failures.items(): + if hits: + raise EsphomeError( + f"store_yaml: sensitive value(s) {why}: {', '.join(hits)}. {_FIX}" + ) + + +def _collect_sensitive_values() -> tuple[ + dict[str, _SensitiveValue], set[tuple[str, ...]] +]: + """Map each cv.sensitive value to its `!secret` name (inline values get one from + their path) and collect every sensitive path, empty values included.""" + used = yaml_util.registered_secret_names() + result: dict[str, _SensitiveValue] = {} + paths: set[tuple[str, ...]] = set() + for path, node, is_key in _iter_nodes(CORE.config): + if is_key or not isinstance(node, yaml_util.Sensitive): + continue + paths.add(path) + if isinstance(node, Lambda): + if _LITERAL_RE.search(node.value) is not None: + raise EsphomeError( + f"store_yaml: the credential at {'.'.join(path)} is set by a " + f"lambda holding a literal, which cannot be redacted. {_FIX}" + ) + continue + if isinstance(node, str) and not node: + continue + # Decimal for ints, to match the raw tree's parsed scalars, not HexInt's hex. + value = str(int(node)) if isinstance(node, int) else str(node) + if (entry := result.get(value)) is None: + name = yaml_util.is_secret(value) + if name is None and _BIND_KEY_RE.fullmatch(value): + # cv.bind_key uppercases the key; its secret keeps the user's spelling + secrets = yaml_util.registered_secret_values().items() + name = next((n for v, n in secrets if v.upper() == value), None) + from_secret = name is not None + if not from_secret: + name = ensure_unique_string("_".join(path) or "secret", used) + used.add(name) + entry = result[value] = _SensitiveValue( + name, ".".join(path), from_secret, set() + ) + entry.is_int = entry.is_int and isinstance(node, int) + entry.sensitive_keys.update(path[-1:]) + return result, paths + + +def _symlink_descriptions(discovered: yaml_util.DiscoveredYamlFiles) -> list[str]: + """Each directory symlink the config went through, as `link -> target`.""" + root = Path(CORE.config_path).absolute().parent + return [ + f"{link.relative_to(root, walk_up=True).as_posix()} -> " + f"{Path(os.path.relpath(target, link.parent.resolve())).as_posix()}" + for link, target in discovered.links.items() + ] + + +def _uncaptured_note( + unresolved: list[str], remote_packages: list[str], symlinks: list[str] +) -> tuple[str, bytes] | None: + """A comment-only entry listing uncaptured content, or None if there is none.""" + sections = ( + ( + "These !include paths use substitutions; restore the files manually:", + unresolved, + ), + ( + "These packages come from remote sources; re-fetch them to complete this config:", + remote_packages, + ), + ("These directories were symlinks; recreate them as link -> target:", symlinks), + ) + body = "".join( + f"# {title}\n" + "".join(f"# {item}\n" for item in items) + for title, items in sections + if items + ) + if not body: + return None + header = "# store_yaml: the following content could not be captured.\n" + return UNCAPTURED_NOTE_PATH, (header + body).encode("utf-8") + + +def _remote_package_descriptions() -> list[str]: + """Each remote package source as its secret name or masked URL, plus ref.""" + + def describe(text: str) -> str: + if (name := yaml_util.is_secret(text)) is not None: + return f"!secret {name}" + # A secret substituted into the URL or ref is shown by name, longest first so + # a secret containing another is replaced whole. + secrets = yaml_util.registered_secret_values().items() + for value, name in sorted(secrets, key=lambda item: -len(item[0])): + if value and value in text: + text = text.replace(value, f"") + return URL_USERINFO_RE.sub("://***@", text) + + return [ + describe(source.url) + (f"@{describe(source.ref)}" if source.ref else "") + for source in packages.get_remote_package_sources() + ] + + +def _build_secrets_skeleton(keys: set[str]) -> bytes: + # Plain PyYAML quotes a key such as "true" and does no secret substitution. + body = yaml.safe_dump(dict.fromkeys(sorted(keys), ""), sort_keys=False) + return (SECRETS_SKELETON_HEADER + (body if keys else "")).encode("utf-8") + + +def _generate_redacted_files( + entries: list[tuple[str, Path]], secret_rels: set[str], remote_packages: list[str] +) -> list[tuple[str, bytes]]: + """Re-dump captured files with sensitive values as `!secret`, secrets files as a + skeleton; comments and formatting are lost.""" + sensitive, sensitive_paths = _collect_sensitive_values() + includes: dict[str, list[_Include]] = {} + removed: dict[str, set[tuple[str, ...]]] = {} + + def prepare( + node: object, + source: Path, + rel: str, + path: tuple[str, ...], + in_vars: bool = False, + ) -> object: + """Fold container secrets to their registered text (a plain str would skip + the secret check), swap integer credentials at their own keys, point each + `!include` at its target's captured name, collect includes and removals.""" + if isinstance(node, (dict, list)) and yaml_util.is_secret(node) is not None: + return yaml_util.make_data_base(str(node)) + if isinstance(node, dict): + for key, value in node.items(): + node[key] = prepare(value, source, rel, (*path, str(key)), in_vars) + elif isinstance(node, list): + for i, item in enumerate(node): + node[i] = prepare(item, source, rel, path, in_vars) + elif isinstance(node, Remove): + # `id: !remove x` drops the whole list item, not just its id. + removed.setdefault(rel, set()).add( + path[:-1] if path[-1:] == (CONF_ID,) else path + ) + elif isinstance(node, (int, str)) and not isinstance(node, bool): + # An integer credential, quoted or not, at its own keys or as a variable + # definition a credential may reference. + info = sensitive.get(str(node)) + at_key = info is not None and info.sensitive_keys.intersection(path[-1:]) + definition = ( + in_vars or path[:1] == (CONF_DEFAULTS,) or _defines_substitution(path) + ) + if info and info.is_int and (at_key or definition): + return yaml_util.make_data_base(_INT_MARK + info.secret_name) + # cv.bind_key uppercases the key, so match an inline one the same way + upper = node.upper() if isinstance(node, str) else "" + bind = sensitive.get(upper) if _BIND_KEY_RE.fullmatch(upper) else None + if bind and bind.sensitive_keys.intersection(path[-1:]): + return yaml_util.make_data_base(upper) + elif isinstance(node, yaml_util.IncludeFile): + vars = _str_keys(node.vars) + if yaml_util.is_secret(node.vars) is not None: + vars = dict.fromkeys(vars, node.vars) + includes.setdefault(rel, []).append((node, vars, path)) + if node.vars: + node.vars = prepare(node.vars, source, rel, path, in_vars=True) + if not node.has_unresolved_file(): + # Where the loader read it, through directory symlinks and `!include_dir_*` moves. + target = yaml_util.named_path(Path(node.parent_file).parent / node.file) + node = node.with_file( + Path(os.path.relpath(target, source.parent)).as_posix() + ) + return node + + for rel, path in entries: + # A secrets file becomes a key skeleton; what it includes would ship verbatim. + if rel in secret_rels and any( + isinstance(token, yaml.TagToken) and token.value[1].startswith("include") + for token in yaml.scan(path.read_text(encoding="utf-8"), yaml.SafeLoader) + ): + raise EsphomeError( + f"store_yaml: {rel} uses !include, which redaction cannot follow. " + f"Inline the files into {rel}, or set `include_secrets: true`." + ) + captured = {rel: path for rel, path in entries if rel not in secret_rels} + trees: dict[str, object] = {} + for rel, path in captured.items(): + # A document that is one `!include` keeps its vars and condition. + with path.open(encoding="utf-8") as handle: + tree = yaml_util.parse_yaml(path, handle, resolve_document_include=False) + trees[rel] = prepare(tree, yaml_util.named_path(path), rel, ()) + scopes, prefixes = _include_scopes(includes, trees, captured) + removed_at = { + merged + for rel, paths in removed.items() + for path in paths + for prefix in prefixes[rel] + for merged in _merged(prefix + path) + if merged + } + + # An integer credential is swapped by prepare(), not by the dumper, so an + # unrelated short number (`accuracy_decimals: 0`) is left alone. + registered = { + (_INT_MARK + info.secret_name if info.is_int else value): info.secret_name + for value, info in sensitive.items() + } + with yaml_util.secret_values_registered(registered) as skeleton_keys: + _check_sensitive_usage( + sensitive, sensitive_paths, trees, scopes, prefixes, removed_at + ) + texts = {rel: yaml_util.dump(tree) for rel, tree in trees.items()} + + # An inline value whose name was never emitted was not found as a whole scalar. + leaked = [ + info.config_path + for info in sensitive.values() + if not info.from_secret and info.secret_name not in skeleton_keys + ] + if leaked: + raise EsphomeError( + f"store_yaml: could not redact the sensitive value(s) of {', '.join(leaked)}; " + "not found in any captured file (composed via substitutions, set with -s, " + f"or defined in a remote package: {', '.join(remote_packages) or 'none'}). " + + _FIX + ) + + skeleton = _build_secrets_skeleton(skeleton_keys) + result = [ + (rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8")) + for rel, _ in entries + ] + if skeleton_keys and yaml_util.SECRET_YAML not in secret_rels: + # `!secret` looks for secrets.yaml beside the config; ship a skeleton there. + result.append((yaml_util.SECRET_YAML, skeleton)) + return result + + +def _pack_envelope(files: list[tuple[str, bytes]]) -> bytes: + """EHY1 | u32 count | {u16 path_len, path, u32 content_len, content}..., little-endian.""" + paths = [path for path, _ in files] + if len(set(paths)) != len(paths): + raise EsphomeError(f"store_yaml: duplicate envelope path in {paths}") + return ( + ENVELOPE_MAGIC + + struct.pack(" dict[str, bytes]: + """Reference decoder; rejects absolute paths, allows `..`, so disk writers confine paths.""" + if blob[:4] != ENVELOPE_MAGIC: + raise EsphomeError("envelope must start with EHY1 magic") + files: dict[str, bytes] = {} + try: + (count,) = struct.unpack_from(" None: + cg.add_define("USE_STORE_YAML") + + # A fresh parse captures only user files and refills the secret registry. + discovered = yaml_util.discover_user_yaml_files(CORE.config_path) + entries, secret_rels = _gather_files(discovered) + remote_packages = _remote_package_descriptions() + if config[CONF_INCLUDE_SECRETS]: + files = _read_files_verbatim(entries) + else: + files = _generate_redacted_files(entries, secret_rels, remote_packages) + if remote_packages: + _LOGGER.warning( + "store_yaml: remote packages are not embedded, only recorded: %s", + ", ".join(remote_packages), + ) + note = _uncaptured_note( + discovered.unresolved, remote_packages, _symlink_descriptions(discovered) + ) + if note is not None: + files.append(note) + envelope = _pack_envelope(files) + compressed = zstd_module().compress(envelope, level=ZSTD_LEVEL) + + _LOGGER.info( + "store_yaml: embedding %d file(s) as %d bytes (%d uncompressed, %.1f%% ratio)", + len(files), + len(compressed), + len(envelope), + 100.0 * len(compressed) / len(envelope), + ) + + cg.extern_progmem_array("esphome::api::STORE_YAML_DATA", cg.uint8, list(compressed)) + cg.add_define("STORE_YAML_DATA_SIZE", len(compressed)) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 4179f7cab12..a3e5ef01034 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable + +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import i2c from esphome.components.const import ( @@ -24,7 +27,7 @@ sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) -SY6970Listener = sy6970_ns.class_("SY6970Listener") +SY6970Data = sy6970_ns.struct("SY6970Data") I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") # REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on @@ -32,6 +35,19 @@ I2C_WATCHDOG_TIMEOUTS = I2CWatchdogTimeout.options( "DISABLED", "40S", "80S", "160S", prefix="I2C_WATCHDOG_" ) +# Publishes the child entities from the registers read on each update +DATA_PUBLISHER = cg.HubFunction( + "data", + cg.void, + [(SY6970Data.const_ref(), "data")], + define="USE_SY6970_DATA_PUBLISHER", +) +DATA = DATA_PUBLISHER.param("data") +REGISTERS = DATA.registers +STATUS_REGISTER = REGISTERS[sy6970_ns.SY6970_REG_STATUS] +BUS_STATUS = (STATUS_REGISTER >> 5) & 0x07 +CHARGE_STATUS = (STATUS_REGISTER >> 3) & 0x03 + CONFIG_SCHEMA = ( cv.Schema( { @@ -73,3 +89,12 @@ async def to_code(config: ConfigType) -> None: config[CONF_ENABLE_ADC], config[CONF_I2C_WATCHDOG_TIMEOUT], ) + + +async def publish_children( + hub: cg.MockObj, sub_entities: Callable, states: dict[str, cg.MockObj] +) -> None: + """Create each configured child and publish its state from the hub's data function.""" + for key, state in states.items(): + if (var := await sub_entities(key, None)) is not None: + DATA_PUBLISHER.add(hub, parent_ref(var).publish_state(state)) diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index a46d7344b68..661e6d2afbe 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -4,7 +4,14 @@ import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import ( + BUS_STATUS, + CHARGE_STATUS, + CONF_SY6970_ID, + SY6970Component, + publish_children, + sy6970_ns, +) DEPENDENCIES = ["sy6970"] @@ -12,39 +19,29 @@ CONF_VBUS_CONNECTED = "vbus_connected" CONF_CHARGING = "charging" CONF_CHARGE_DONE = "charge_done" -SY6970VbusConnectedBinarySensor = sy6970_ns.class_( - "SY6970VbusConnectedBinarySensor", binary_sensor.BinarySensor -) -SY6970ChargingBinarySensor = sy6970_ns.class_( - "SY6970ChargingBinarySensor", binary_sensor.BinarySensor -) -SY6970ChargeDoneBinarySensor = sy6970_ns.class_( - "SY6970ChargeDoneBinarySensor", binary_sensor.BinarySensor -) - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), cv.Optional(CONF_VBUS_CONNECTED): binary_sensor.binary_sensor_schema( - SY6970VbusConnectedBinarySensor, device_class=DEVICE_CLASS_CONNECTIVITY, ), cv.Optional(CONF_CHARGING): binary_sensor.binary_sensor_schema( - SY6970ChargingBinarySensor, device_class=DEVICE_CLASS_POWER, ), cv.Optional(CONF_CHARGE_DONE): binary_sensor.binary_sensor_schema( - SY6970ChargeDoneBinarySensor, device_class=DEVICE_CLASS_POWER, ), } ) +STATES = { + CONF_VBUS_CONNECTED: BUS_STATUS != sy6970_ns.BUS_STATUS_NO_INPUT, + CONF_CHARGING: sy6970_ns.is_charging(CHARGE_STATUS), + CONF_CHARGE_DONE: CHARGE_STATUS == sy6970_ns.CHARGE_STATUS_CHARGE_DONE, +} + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_VBUS_CONNECTED, hub.add_listener) - await binary_sensors(CONF_CHARGING, hub.add_listener) - await binary_sensors(CONF_CHARGE_DONE, hub.add_listener) + await publish_children(hub, binary_sensor.sub_binary_sensors(config), STATES) diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h deleted file mode 100644 index b94c89d123e..00000000000 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/binary_sensor/binary_sensor.h" - -namespace esphome::sy6970 { - -template -class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t value = (data.registers[REG] >> SHIFT) & MASK; - this->publish_state(value == TRUE_VALUE); - } -}; - -template -class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t value = (data.registers[REG] >> SHIFT) & MASK; - this->publish_state(value != FALSE_VALUE); - } -}; - -// Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; - bool charging = chrg_stat != CHARGE_STATUS_NOT_CHARGING && chrg_stat != CHARGE_STATUS_CHARGE_DONE; - this->publish_state(charging); - } -}; - -// Specialized sensor types using templates -// VBUS connected: BUS_STATUS != NO_INPUT -using SY6970VbusConnectedBinarySensor = InverseStatusBinarySensor; - -// Charge done: CHARGE_STATUS == CHARGE_DONE -using SY6970ChargeDoneBinarySensor = StatusBinarySensor; - -} // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index e2f12501ce1..46573552d56 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -12,56 +12,94 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import CONF_SY6970_ID, REGISTERS, SY6970Component, publish_children, sy6970_ns DEPENDENCIES = ["sy6970"] CONF_VBUS_VOLTAGE = "vbus_voltage" CONF_SYSTEM_VOLTAGE = "system_voltage" -SY6970VbusVoltageSensor = sy6970_ns.class_("SY6970VbusVoltageSensor", sensor.Sensor) -SY6970BatteryVoltageSensor = sy6970_ns.class_( - "SY6970BatteryVoltageSensor", sensor.Sensor -) -SY6970SystemVoltageSensor = sy6970_ns.class_("SY6970SystemVoltageSensor", sensor.Sensor) -SY6970ChargeCurrentSensor = sy6970_ns.class_("SY6970ChargeCurrentSensor", sensor.Sensor) -SY6970PrechargeCurrentSensor = sy6970_ns.class_( - "SY6970PrechargeCurrentSensor", sensor.Sensor -) + +def _decode( + reg: cg.MockObj, + step: cg.MockObj, + base: cg.MockObj | None = None, + *, + shift: int = 0, + mask: int = 0x7F, + scale: float | None = None, +) -> cg.MockObj: + """``(base + ((registers[reg] >> shift) & mask) * step) * scale``.""" + raw = REGISTERS[reg] + if shift: + raw = raw >> shift + value = (raw & mask) * step + if base is not None: + value = base + value + return value if scale is None else value * scale + + +# Voltages in V from mV, currents in mA +STATES = { + CONF_VBUS_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_VBUS_VOLTAGE, + sy6970_ns.VBUS_STEP_MV, + sy6970_ns.VBUS_BASE_MV, + scale=0.001, + ), + CONF_BATTERY_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_BATV, + sy6970_ns.VBAT_STEP_MV, + sy6970_ns.VBAT_BASE_MV, + scale=0.001, + ), + CONF_SYSTEM_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_VINDPM_STATUS, + sy6970_ns.VSYS_STEP_MV, + sy6970_ns.VSYS_BASE_MV, + scale=0.001, + ), + CONF_CHARGE_CURRENT: _decode( + sy6970_ns.SY6970_REG_CHARGE_CURRENT_MONITOR, sy6970_ns.CHG_CURRENT_STEP_MA + ), + CONF_PRECHARGE_CURRENT: _decode( + sy6970_ns.SY6970_REG_PRECHARGE_CURRENT, + sy6970_ns.PRE_CHG_STEP_MA, + sy6970_ns.PRE_CHG_BASE_MA, + shift=4, + mask=0x0F, + ), +} + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), cv.Optional(CONF_VBUS_VOLTAGE): sensor.sensor_schema( - SY6970VbusVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( - SY6970BatteryVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_SYSTEM_VOLTAGE): sensor.sensor_schema( - SY6970SystemVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CHARGE_CURRENT): sensor.sensor_schema( - SY6970ChargeCurrentSensor, unit_of_measurement=UNIT_MILLIAMP, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_PRECHARGE_CURRENT): sensor.sensor_schema( - SY6970PrechargeCurrentSensor, unit_of_measurement=UNIT_MILLIAMP, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, @@ -74,9 +112,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - sensors = sensor.sub_sensors(config) - await sensors(CONF_VBUS_VOLTAGE, hub.add_listener) - await sensors(CONF_BATTERY_VOLTAGE, hub.add_listener) - await sensors(CONF_SYSTEM_VOLTAGE, hub.add_listener) - await sensors(CONF_CHARGE_CURRENT, hub.add_listener) - await sensors(CONF_PRECHARGE_CURRENT, hub.add_listener) + await publish_children(hub, sensor.sub_sensors(config), STATES) diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h deleted file mode 100644 index 61abbc3e36b..00000000000 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/sensor/sensor.h" - -namespace esphome::sy6970 { - -// Template for voltage sensors (converts mV to V) -template -class VoltageSensor : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t val = data.registers[REG] & MASK; - uint16_t voltage_mv = BASE + (val * STEP); - this->publish_state(voltage_mv * 0.001f); // Convert mV to V - } -}; - -// Template for current sensors (returns mA) -template -class CurrentSensor : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t val = data.registers[REG] & MASK; - uint16_t current_ma = BASE + (val * STEP); - this->publish_state(current_ma); - } -}; - -// Specialized sensor types using templates -using SY6970VbusVoltageSensor = VoltageSensor; -using SY6970BatteryVoltageSensor = VoltageSensor; -using SY6970SystemVoltageSensor = VoltageSensor; -using SY6970ChargeCurrentSensor = CurrentSensor; - -// Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; - uint16_t iprechg_ma = PRE_CHG_BASE_MA + (iprechg * PRE_CHG_STEP_MA); - this->publish_state(iprechg_ma); - } -}; - -} // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index a5f6e620d15..51a93b16cd8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -123,10 +123,10 @@ void SY6970Component::update() { return; } - // Notify all listeners with the new data - for (auto *listener : this->listeners_) { - listener->on_data(this->data_); - } +#ifdef USE_SY6970_DATA_PUBLISHER + if (this->data_publisher_ != nullptr) + this->data_publisher_(this->data_); +#endif } void SY6970Component::set_input_current_limit(uint16_t milliamps) { diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index b9c4f1d4870..86a04238ec3 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -2,7 +2,9 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#include +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome::sy6970 { @@ -76,11 +78,15 @@ struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) }; -// Listener interface for components that want to receive SY6970 data updates -class SY6970Listener { - public: - virtual void on_data(const SY6970Data &data) = 0; -}; +inline bool is_charging(uint8_t charge_status) { + return charge_status == CHARGE_STATUS_PRE_CHARGE || charge_status == CHARGE_STATUS_FAST_CHARGE; +} + +// Indexed by status value; every value of the masked field has a string +PROGMEM_STRING_TABLE(BusStatusStrings, "No Input", "USB SDP", "USB CDP", "USB DCP", "HVDCP", "Adapter", + "Non-Standard Adapter", "OTG"); +PROGMEM_STRING_TABLE(ChargeStatusStrings, "Not Charging", "Pre-charge", "Fast Charge", "Charge Done"); +PROGMEM_STRING_TABLE(NtcStatusStrings, "Normal", "Unknown", "Warm", "Cool", "Unknown", "Cold", "Hot", "Unknown"); class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: @@ -99,8 +105,9 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void dump_config() override; void update() override; - // Listener registration - void add_listener(SY6970Listener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_SY6970_DATA_PUBLISHER + HUB_PUBLISHER(data, const SY6970Data &) +#endif // Configuration methods to be called from lambdas void set_input_current_limit(uint16_t milliamps); @@ -121,7 +128,6 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); SY6970Data data_{}; - std::vector listeners_; // Configuration values to set during setup() bool led_enabled_; diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index db756960e1b..a0e921db64f 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -3,7 +3,15 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import ( + BUS_STATUS, + CHARGE_STATUS, + CONF_SY6970_ID, + REGISTERS, + SY6970Component, + publish_children, + sy6970_ns, +) DEPENDENCIES = ["sy6970"] @@ -11,36 +19,32 @@ CONF_BUS_STATUS = "bus_status" CONF_CHARGE_STATUS = "charge_status" CONF_NTC_STATUS = "ntc_status" -SY6970BusStatusTextSensor = sy6970_ns.class_( - "SY6970BusStatusTextSensor", text_sensor.TextSensor -) -SY6970ChargeStatusTextSensor = sy6970_ns.class_( - "SY6970ChargeStatusTextSensor", text_sensor.TextSensor -) -SY6970NtcStatusTextSensor = sy6970_ns.class_( - "SY6970NtcStatusTextSensor", text_sensor.TextSensor -) - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), - cv.Optional(CONF_BUS_STATUS): text_sensor.text_sensor_schema( - SY6970BusStatusTextSensor - ), - cv.Optional(CONF_CHARGE_STATUS): text_sensor.text_sensor_schema( - SY6970ChargeStatusTextSensor - ), - cv.Optional(CONF_NTC_STATUS): text_sensor.text_sensor_schema( - SY6970NtcStatusTextSensor - ), + cv.Optional(CONF_BUS_STATUS): text_sensor.text_sensor_schema(), + cv.Optional(CONF_CHARGE_STATUS): text_sensor.text_sensor_schema(), + cv.Optional(CONF_NTC_STATUS): text_sensor.text_sensor_schema(), } ) +def _status_string(table: str, index: cg.MockObj) -> cg.MockObj: + """The string at ``index`` in the hub's PROGMEM_STRING_TABLE ``table``.""" + strings = sy6970_ns.namespace(table) + return strings.get_progmem_str(index, strings.LAST_INDEX) + + +STATES = { + CONF_BUS_STATUS: _status_string("BusStatusStrings", BUS_STATUS), + CONF_CHARGE_STATUS: _status_string("ChargeStatusStrings", CHARGE_STATUS), + CONF_NTC_STATUS: _status_string( + "NtcStatusStrings", REGISTERS[sy6970_ns.SY6970_REG_FAULT] & 0x07 + ), +} + + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_BUS_STATUS, hub.add_listener) - await text_sensors(CONF_CHARGE_STATUS, hub.add_listener) - await text_sensors(CONF_NTC_STATUS, hub.add_listener) + await publish_children(hub, text_sensor.sub_text_sensors(config), STATES) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h deleted file mode 100644 index e569bd0b902..00000000000 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::sy6970 { - -// Bus status text sensor -class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; - const char *status_str = this->get_bus_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_bus_status_string_(uint8_t status) { - switch (status) { - case BUS_STATUS_NO_INPUT: - return "No Input"; - case BUS_STATUS_USB_SDP: - return "USB SDP"; - case BUS_STATUS_USB_CDP: - return "USB CDP"; - case BUS_STATUS_USB_DCP: - return "USB DCP"; - case BUS_STATUS_HVDCP: - return "HVDCP"; - case BUS_STATUS_ADAPTER: - return "Adapter"; - case BUS_STATUS_NO_STD_ADAPTER: - return "Non-Standard Adapter"; - case BUS_STATUS_OTG: - return "OTG"; - default: - return "Unknown"; - } - } -}; - -// Charge status text sensor -class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; - const char *status_str = this->get_charge_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_charge_status_string_(uint8_t status) { - switch (status) { - case CHARGE_STATUS_NOT_CHARGING: - return "Not Charging"; - case CHARGE_STATUS_PRE_CHARGE: - return "Pre-charge"; - case CHARGE_STATUS_FAST_CHARGE: - return "Fast Charge"; - case CHARGE_STATUS_CHARGE_DONE: - return "Charge Done"; - default: - return "Unknown"; - } - } -}; - -// NTC status text sensor -class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; - const char *status_str = this->get_ntc_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_ntc_status_string_(uint8_t status) { - switch (status) { - case 0: - return "Normal"; - case 2: - return "Warm"; - case 3: - return "Cool"; - case 5: - return "Cold"; - case 6: - return "Hot"; - default: - return "Unknown"; - } - } -}; - -} // namespace esphome::sy6970 diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index 2c33df3db91..da195527c55 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -16,15 +16,6 @@ DOMAIN = "tca8418" AUTO_LOAD = ["key_provider", "gpio_expander"] MULTI_CONF = True -# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs -_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") - - -async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: - _request_listener_slot(str(keypad)) - cg.add(keypad.register_listener(var)) - - CONF_GPI_EVENTS = "gpi_events" CONF_KEYPAD_ID = "keypad_id" @@ -45,6 +36,16 @@ TCA8418Component = tca8418_ns.class_( "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice ) +# Publishes the binary sensors on each key event +KEY_PUBLISHER = cg.HubFunction( + "key", + cg.void, + [(cg.uint8, "key"), (cg.bool_, "pressed")], + define="USE_TCA8418_KEY_PUBLISHER", +) +KEY = KEY_PUBLISHER.param("key") +PRESSED = KEY_PUBLISHER.param("pressed") + def _ascii_string(value: str) -> str: """A string of single byte characters, so it lines up with the matrix.""" diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py index ee9086dd184..c1a25adec0d 100644 --- a/esphome/components/tca8418/binary_sensor/__init__.py +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -1,3 +1,4 @@ +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS @@ -11,12 +12,14 @@ from .. import ( CONF_KEYPAD_ID, GPI_KEY_MAX, GPI_KEY_MIN, + KEY, + KEY_PUBLISHER, MATRIX_COLUMNS, MATRIX_KEY_MAX, + PRESSED, TCA8418Component, _ascii_string, _matrix_key, - register_listener, tca8418_ns, ) @@ -24,10 +27,6 @@ DEPENDENCIES = ["tca8418"] CONF_KEY_CODE = "key_code" -TCA8418BinarySensor = tca8418_ns.class_( - "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff -) - def _key_code(value: int) -> int: value = cv.int_(value) @@ -42,7 +41,7 @@ def _key_code(value: int) -> int: _POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" CONFIG_SCHEMA = cv.All( - binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff).extend( { cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( @@ -136,6 +135,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + var = await binary_sensor.new_binary_sensor(config) keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) - await register_listener(keypad, var) + key_code = config[CONF_KEY_CODE] + KEY_PUBLISHER.add( + keypad, tca8418_ns.publish_key(parent_ref(var), KEY, key_code, PRESSED) + ) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h deleted file mode 100644 index d6d58b93bc4..00000000000 --- a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/components/binary_sensor/binary_sensor.h" -#include "../tca8418.h" - -namespace esphome::tca8418 { - -/// One key of a TCA8418, matched by the key number codegen resolved from the config -class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { - public: - explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} - - void key_pressed(uint8_t key) override { - if (key == this->key_) - this->publish_state(true); - } - void key_released(uint8_t key) override { - if (key == this->key_) - this->publish_state(false); - } - - protected: - const uint8_t key_; -}; - -} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 1fa4869757b..7592b78bfaa 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -120,14 +120,9 @@ uint8_t TCA8418Component::key_char_(uint8_t key) const { void TCA8418Component::dispatch_(uint8_t key, bool pressed) { ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); -#ifdef TCA8418_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (pressed) { - listener->key_pressed(key); - } else { - listener->key_released(key); - } - } +#ifdef USE_TCA8418_KEY_PUBLISHER + if (this->key_publisher_ != nullptr) + this->key_publisher_(key, pressed); #endif if (!pressed) diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h index 83d564d742c..1921d870b96 100644 --- a/esphome/components/tca8418/tca8418.h +++ b/esphome/components/tca8418/tca8418.h @@ -31,12 +31,11 @@ static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; // The event queue holds ten entries, which bounds how many can be read at once. static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; -class TCA8418Listener { - public: - /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). - virtual void key_pressed(uint8_t key) {} - virtual void key_released(uint8_t key) {} -}; +// A template on the sensor type keeps binary_sensor.h out of this hub header +template inline void publish_key(T *sensor, uint8_t key, uint8_t sensor_key, bool pressed) { + if (key == sensor_key) + sensor->publish_state(pressed); +} class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { public: @@ -53,8 +52,9 @@ class TCA8418Component : public key_provider::KeyProvider, public Component, pub } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } -#ifdef TCA8418_LISTENER_COUNT - void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_TCA8418_KEY_PUBLISHER + // key is 1..80 for the matrix, 97..114 for individual inputs + HUB_PUBLISHER(key, uint8_t, bool) #endif /// Wakes the loop so queued events are read. Runs in interrupt context. @@ -73,9 +73,6 @@ class TCA8418Component : public key_provider::KeyProvider, public Component, pub const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 InternalGPIOPin *interrupt_pin_{nullptr}; uint32_t last_poll_{0}; -#ifdef TCA8418_LISTENER_COUNT - StaticVector listeners_; -#endif uint8_t rows_{0}; uint8_t columns_{0}; uint8_t keys_size_{0}; diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 62a739a9e63..bd7de188418 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,12 +16,8 @@ namespace esphome::tca9555 { ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { - if (!this->setup_check(this->read_gpio_modes_())) + if (!this->setup_check(this->read_gpio_modes_() && this->read_gpio_outputs_())) return; - if (!this->read_gpio_outputs_()) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } this->setup_interrupt_pin_(); } @@ -68,21 +64,11 @@ bool TCA9555Component::digital_read_hw(uint8_t pin) { if (this->is_failed()) return false; uint8_t data; - uint8_t bank_number = pin < 8 ? 0 : 1; - uint8_t register_to_read = bank_number ? TCA9555_INPUT_PORT_REGISTER_1 : TCA9555_INPUT_PORT_REGISTER_0; - if (!this->read_bytes(register_to_read, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read input register")); + if (!this->status_comm_result( + this->read_byte(pin < 8 ? TCA9555_INPUT_PORT_REGISTER_0 : TCA9555_INPUT_PORT_REGISTER_1, &data), + LOG_STR("Failed to read input register"))) return false; - } - uint8_t second_half = this->input_mask_ >> 8; - uint8_t first_half = this->input_mask_; - if (bank_number) { - this->input_mask_ = (data << 8) | (uint16_t(first_half) << 0); - } else { - this->input_mask_ = (uint16_t(second_half) << 8) | (data << 0); - } - - this->status_clear_warning(); + this->input_[pin / 8] = data; return true; } @@ -103,8 +89,6 @@ bool TCA9555Component::write_gpio_modes_() { LOG_STR("Failed to write mode register")); } -bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - } // namespace esphome::tca9555 ESPHOME_EXPANDER_PIN_SUMMARY(esphome::tca9555::TCA9555Component, "%u via TCA9555") diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 2e419586267..7571cb8ae2e 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -21,15 +21,12 @@ class TCA9555Component final : public gpio_expander::CachedGpioExpanderComponent protected: // Virtual methods from GpioExpander base class — names come from base bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) - bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) /// Mask for the pin mode - 1 means output, 0 means input uint16_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state read in digital_read_hw - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; bool read_gpio_modes_(); bool write_gpio_modes_(); diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py index 3fb9ed20a66..53663a5d737 100644 --- a/esphome/components/tfluna/sensor.py +++ b/esphome/components/tfluna/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_TEMPERATURE, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, - DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, ICON_ARROW_EXPAND_VERTICAL, @@ -45,7 +44,6 @@ CONFIG_SCHEMA = { cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( icon=ICON_SIGNAL, accuracy_decimals=0, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index f47109b9cb3..dd5cb8b1714 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -29,24 +29,24 @@ ThermoProBLE = thermopro_ble_ns.class_( "ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component ) + +def _temperature_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + + CONFIG_SCHEMA = cv.All( ble_device_base.rename_legacy_hub_id("thermopro_ble"), cv.Schema( { cv.GenerateID(): cv.declare_id(ThermoProBLE), cv.Required(CONF_MAC_ADDRESS): cv.mac_address, - cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_EXTERNAL_TEMPERATURE): sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Optional(CONF_TEMPERATURE): _temperature_schema(), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): _temperature_schema(), cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0, @@ -75,9 +75,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await ble_device_base.new_ble_device_component(config) - - cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex + ) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 11a4ec3de1f..20f99a5626f 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,28 +1,74 @@ #include "thermopro_ble.h" +#include #include #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::thermopro_ble { -// this size must be large enough to hold the largest data frame -// of all supported devices -static constexpr std::size_t MAX_DATA_SIZE = 24; +ESPHOME_LOG_TAG(TAG, "thermopro_ble"); + +// Largest record (company ID plus payload) of all supported devices +static constexpr size_t MAX_DATA_SIZE = 24; + +struct ParseResult { + float temperature{NAN}; + float external_temperature{NAN}; + float humidity{NAN}; + float battery_level{NAN}; +}; + +using DeviceParser = bool (*)(const uint8_t *data, size_t data_size, ParseResult &result); + +// Fitted A*tanh(B*x+C)+D to the TP96x battery voltage curve, used with permission from +// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py +static float tp96_battery(uint16_t voltage) { + float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + return clamp(level, 0.0f, 100.0f); +} + +static bool parse_tp972(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size != 23) { + ESP_LOGVV(TAG, "TP972 payload size %u != 23", static_cast(data_size)); + return false; + } + // TODO: both temperatures are °F + 54 and the internal one is a float; fixed in a follow-up + result.external_temperature = static_cast(encode_value_le(data + 1)) - 54.0f; + result.battery_level = tp96_battery(encode_value_le(data + 3)); + result.temperature = static_cast(encode_value_le(data + 9)) - 54.0f; + return true; +} + +static bool parse_tp96(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size != 7) { + ESP_LOGVV(TAG, "TP96 payload size %u != 7", static_cast(data_size)); + return false; + } + // Temperatures are offset by 30 °C + result.temperature = static_cast(encode_value_le(data + 1)) - 30.0f; + result.battery_level = tp96_battery(encode_value_le(data + 3)); + result.external_temperature = static_cast(encode_value_le(data + 5)) - 30.0f; + return true; +} + +static bool parse_tp3(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size < 6) { + ESP_LOGVV(TAG, "TP3 payload size %u < 6", static_cast(data_size)); + return false; + } + result.temperature = static_cast(encode_value_le(data + 1)) * 0.1f; + result.humidity = static_cast(data[3]); + // Two battery bits, 50 % per step + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; + return true; +} struct DeviceParserMapping { const char *prefix; DeviceParser parser; }; -static float tp96_battery(uint16_t voltage); - -static optional parse_tp972(const uint8_t *data, std::size_t data_size); -static optional parse_tp96(const uint8_t *data, std::size_t data_size); -static optional parse_tp3(const uint8_t *data, std::size_t data_size); - -ESPHOME_LOG_TAG(TAG, "thermopro_ble"); - -static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { +static constexpr DeviceParserMapping DEVICE_PARSER_MAP[] = { {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; static DeviceParser find_device_parser(StringRef device_name) { @@ -30,155 +76,60 @@ static DeviceParser find_device_parser(StringRef device_name) { if (device_name.starts_with(mapping.prefix)) return mapping.parser; } - ESP_LOGVV(TAG, "find_device_parser(): unknown device type %s.", device_name.c_str()); return nullptr; } +static void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr && !std::isnan(value)) + sensor->publish_state(value); +} + void ThermoProBLE::dump_config() { ESP_LOGCONFIG(TAG, "ThermoPro BLE"); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - LOG_SENSOR(" ", "External temperature", this->external_temperature_sensor_); + LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); } bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { - // check for matching mac address - if (device.address_uint64() != this->address_) { - ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + if (device.address_uint64() != this->address_) return false; - } - // check for valid device type DeviceParser parser = find_device_parser(device.get_name()); if (parser == nullptr) { - ESP_LOGVV(TAG, "parse_device(): invalid device type."); + ESP_LOGVV(TAG, "Unknown device type %s", device.get_name().c_str()); return false; } - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str_to(addr_buf)); - - // publish signal strength - float signal_strength = float(device.get_rssi()); - PUBLISH_STATE(this->signal_strength_sensor_, signal_strength); + PUBLISH_STATE(this->signal_strength_sensor_, device.get_rssi()); bool success = false; - for (auto &service_data : device.get_manufacturer_datas()) { - // check maximum data size - std::size_t data_size = service_data.data.size() + 2; + for (const auto &mfg_data : device.get_manufacturer_datas()) { + size_t data_size = mfg_data.data.size() + 2; if (data_size > MAX_DATA_SIZE) { - ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!"); + ESP_LOGVV(TAG, "Payload size %u > %u", static_cast(data_size), static_cast(MAX_DATA_SIZE)); continue; } - // reconstruct whole record from 2 byte uuid and data - uint16_t svc_uuid16 = service_data.uuid.uuid16(); - uint8_t data[MAX_DATA_SIZE] = {static_cast(svc_uuid16), static_cast(svc_uuid16 >> 8)}; - std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); + // The parsers index the whole record, company ID included + uint16_t company_id = mfg_data.uuid.uuid16(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(company_id), static_cast(company_id >> 8)}; + std::copy(mfg_data.data.begin(), mfg_data.data.end(), data + 2); - // dispatch data to parser - optional result = parser(data, data_size); - if (!result.has_value()) { + ParseResult result; + if (!parser(data, data_size, result)) continue; - } - - // publish sensor values - publish_optional(this->temperature_sensor_, result->temperature); - publish_optional(this->external_temperature_sensor_, result->external_temperature); - publish_optional(this->humidity_sensor_, result->humidity); - publish_optional(this->battery_level_sensor_, result->battery_level); + publish_if_set(this->temperature_sensor_, result.temperature); + publish_if_set(this->external_temperature_sensor_, result.external_temperature); + publish_if_set(this->humidity_sensor_, result.humidity); + publish_if_set(this->battery_level_sensor_, result.battery_level); success = true; } return success; } -static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -// Battery calculation used with permission from: -// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py -// -// TP96x battery values appear to be a voltage reading, probably in millivolts. -// This means that calculating battery life from it is a non-linear function. -// Examining the curve, it looked fairly close to a curve from the tanh function. -// So, I created a script to use Tensorflow to optimize an equation in the format -// A*tanh(B*x+C)+D -// Where A,B,C,D are the variables to optimize for. This yielded the below function -static float tp96_battery(uint16_t voltage) { - float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; - return std::max(0.0f, std::min(level, 100.0f)); -} - -static optional parse_tp972(const uint8_t *data, std::size_t data_size) { - if (data_size != 23) { - ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size); - return {}; - } - - ParseResult result; - - // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset - result.external_temperature = static_cast(read_uint16(data, 1)) - 54.0f; - - // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) - result.battery_level = tp96_battery(read_uint16(data, 3)); - - // internal temperature, 4 bytes, float, -54 °C offset - result.temperature = static_cast(read_uint32(data, 9)) - 54.0f; - - return result; -} - -static optional parse_tp96(const uint8_t *data, std::size_t data_size) { - if (data_size != 7) { - ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size); - return {}; - } - - ParseResult result; - - // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset - result.temperature = static_cast(read_uint16(data, 1)) - 30.0f; - - // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) - result.battery_level = tp96_battery(read_uint16(data, 3)); - - // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset - result.external_temperature = static_cast(read_uint16(data, 5)) - 30.0f; - - return result; -} - -static optional parse_tp3(const uint8_t *data, std::size_t data_size) { - if (data_size < 6) { - ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size); - return {}; - } - - ParseResult result; - - // temperature, 2 bytes, 16-bit signed integer, 0.1 °C - result.temperature = static_cast(read_int16(data, 1)) * 0.1f; - - // humidity, 1 byte, 8-bit unsigned integer, 1.0 % - result.humidity = static_cast(data[3]); - - // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0f; - - return result; -} - } // namespace esphome::thermopro_ble diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index fc09cbb150f..0216d29b506 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -6,15 +6,6 @@ namespace esphome::thermopro_ble { -struct ParseResult { - optional temperature; - optional external_temperature; - optional humidity; - optional battery_level; -}; - -using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); - class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener { SUB_SENSOR(signal_strength) SUB_SENSOR(temperature) @@ -23,13 +14,13 @@ class ThermoProBLE final : public Component, public ble_device_base::ESPBTDevice SUB_SENSOR(battery_level) public: - void set_address(uint64_t address) { this->address_ = address; }; + explicit ThermoProBLE(uint64_t address) : address_(address) {} bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; protected: - uint64_t address_; + const uint64_t address_; }; } // namespace esphome::thermopro_ble diff --git a/esphome/components/tm1638/binary_sensor/__init__.py b/esphome/components/tm1638/binary_sensor/__init__.py index 4f89b7bf5ef..7225757e1bd 100644 --- a/esphome/components/tm1638/binary_sensor/__init__.py +++ b/esphome/components/tm1638/binary_sensor/__init__.py @@ -1,14 +1,13 @@ +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_KEY from esphome.types import ConfigType -from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns +from ..display import CONF_TM1638_ID, KEYS, KEYS_PUBLISHER, TM1638Component, tm1638_ns -TM1638Key = tm1638_ns.class_("TM1638Key", binary_sensor.BinarySensor) - -CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( { cv.GenerateID(CONF_TM1638_ID): cv.use_id(TM1638Component), cv.Required(CONF_KEY): cv.int_range(min=0, max=15), @@ -18,6 +17,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) - cg.add(var.set_keycode(config[CONF_KEY])) hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(hub.register_listener(var)) + KEYS_PUBLISHER.add( + hub, + tm1638_ns.publish_key(parent_ref(var), KEYS, config[CONF_KEY]), + ) diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp b/esphome/components/tm1638/binary_sensor/tm1638_key.cpp deleted file mode 100644 index 9eecf97a9bd..00000000000 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include "tm1638_key.h" - -namespace esphome::tm1638 { - -void TM1638Key::keys_update(uint8_t keys) { - bool pressed = keys & (1 << key_code_); - if (pressed != this->state) - this->publish_state(pressed); -} - -} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h deleted file mode 100644 index 1e6336a1f4a..00000000000 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/binary_sensor/binary_sensor.h" -#include "../tm1638.h" - -namespace esphome::tm1638 { - -class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { - public: - void set_keycode(uint8_t key_code) { key_code_ = key_code; }; - void keys_update(uint8_t keys) override; - - protected: - uint8_t key_code_{0}; -}; - -} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index d7e375ac885..783f4059bab 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -19,6 +19,15 @@ tm1638_ns = cg.esphome_ns.namespace("tm1638") TM1638Component = tm1638_ns.class_("TM1638Component", cg.PollingComponent) TM1638ComponentRef = TM1638Component.operator("ref") +# Publishes the key binary sensors from the key bitmask read on each loop +KEYS_PUBLISHER = cg.HubFunction( + "keys", + cg.void, + [(cg.uint8, "keys")], + define="USE_TM1638_KEYS_PUBLISHER", +) +KEYS = KEYS_PUBLISHER.param("keys") + CONFIG_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( { diff --git a/esphome/components/tm1638/tm1638.cpp b/esphome/components/tm1638/tm1638.cpp index ffb0ead0cd8..0bfeb99cf89 100644 --- a/esphome/components/tm1638/tm1638.cpp +++ b/esphome/components/tm1638/tm1638.cpp @@ -48,12 +48,10 @@ void TM1638Component::dump_config() { } void TM1638Component::loop() { - if (this->listeners_.empty()) - return; - - uint8_t keys = this->get_keys(); - for (auto &listener : this->listeners_) - listener->keys_update(keys); +#ifdef USE_TM1638_KEYS_PUBLISHER + if (this->keys_publisher_ != nullptr) + this->keys_publisher_(this->get_keys()); +#endif } uint8_t TM1638Component::get_keys() { diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index ef791a07753..36b05b0f8cd 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -4,17 +4,18 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/components/display/display.h" -#include - namespace esphome::tm1638 { -class KeyListener { - public: - virtual void keys_update(uint8_t keys) {}; -}; +// A template on the sensor type keeps binary_sensor.h out of this hub header +template inline void publish_key(T *sensor, uint8_t keys, uint8_t key) { + bool pressed = keys & (1 << key); + if (pressed != sensor->state) + sensor->publish_state(pressed); +} class TM1638Component; @@ -34,7 +35,9 @@ class TM1638Component final : public PollingComponent { void set_dio_pin(GPIOPin *pin) { this->dio_pin_ = pin; } void set_stb_pin(GPIOPin *pin) { this->stb_pin_ = pin; } - void register_listener(KeyListener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_TM1638_KEYS_PUBLISHER + HUB_PUBLISHER(keys, uint8_t) +#endif /// Evaluate the printf-format and print the result at the given position. uint8_t printf(uint8_t pos, const char *format, ...) __attribute__((format(printf, 3, 4))); @@ -71,7 +74,6 @@ class TM1638Component final : public PollingComponent { GPIOPin *dio_pin_; uint8_t buffer_[8]{}; tm1638_writer_t writer_{}; - std::vector listeners_{}; }; } // namespace esphome::tm1638 diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 7dbe6188f4f..a6991e269de 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -38,16 +38,18 @@ DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" CONF_PRODUCT = "product" +CONF_INTERFACE_DESCRIPTION = "interface_description" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" -# VID/PID set to 0 or `None` product/manufacturer are wildcards +# VID/PID set to 0 or `None` strings are wildcards _FILTER_WILDCARDS = { CONF_VID: 0, CONF_PID: 0, CONF_MANUFACTURER: None, CONF_PRODUCT: None, + CONF_INTERFACE_DESCRIPTION: None, } @@ -72,6 +74,7 @@ def usb_device_schema( { cv.Optional(CONF_MANUFACTURER): cv.string_strict, cv.Optional(CONF_PRODUCT): cv.string_strict, + cv.Optional(CONF_INTERFACE_DESCRIPTION): cv.string_strict, } ) @@ -297,6 +300,10 @@ async def register_usb_client(config: ConfigType) -> MockObj: ) if (product := config.get(CONF_PRODUCT)) is not None: cg.add(var.set_product_filter(cg.RawExpression(cpp_u16string_escape(product)))) + if (interface := config.get(CONF_INTERFACE_DESCRIPTION)) is not None: + cg.add( + var.set_interface_filter(cg.RawExpression(cpp_u16string_escape(interface))) + ) return var diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index bbb6f15f292..2a9ead39c9a 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -57,6 +57,12 @@ static constexpr uint8_t USB_DIR_MASK = 1 << 7; static constexpr uint8_t USB_DIR_IN = 1 << 7; static constexpr uint8_t USB_DIR_OUT = 0; static constexpr size_t SETUP_PACKET_SIZE = 8; +// A string descriptor is at most 255 bytes, since bLength is one byte +static constexpr size_t STRING_DESC_MAX_SIZE = 255; +// Room for the setup packet and the largest string, read in whole packets of a 64-byte EP0 +static constexpr size_t STRING_TRANSFER_SIZE = SETUP_PACKET_SIZE + 256; +// The host stack enumerates with this language only, so the strings it caches use it too +static constexpr uint16_t STRING_DESC_LANGID = 0x0409; static constexpr size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible. static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be between 1 and 32"); @@ -135,9 +141,9 @@ struct UsbDeviceInfo { }; /// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as -/// an empty string. Returns false when a descriptor contains non-ASCII characters, -/// UTF-16 to UTF-8 conversion is not currently implemented. -bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); +/// an empty string. Returns false, leaving the buffer empty, when the string does not fit or +/// contains non-ASCII characters; UTF-16 to UTF-8 conversion is not currently implemented. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); enum ClientState { USB_CLIENT_INIT = 0, @@ -167,6 +173,8 @@ class USBClient : public Component { const std::vector &data = {}); void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; } void set_product_filter(const char16_t *product) { this->product_filter_ = product; } + /// A device matches when any of its interfaces carries exactly this iInterface string + void set_interface_filter(const char16_t *interface) { this->interface_filter_ = interface; } /// Whether a device has been opened and its setup by the subclass has finished bool is_connected() const { return this->connection_reported_; } @@ -199,7 +207,21 @@ class USBClient : public Component { // with their own work check for a single disable_loop() decision. bool process_usb_events_(); void handle_open_state_(); + void handle_get_desc_state_(); + void finish_open_(); + /// Start reading the iInterface string of an interface; the host stack does not cache + /// those. Completes on the main loop through on_interface_string(), one read at a time. + /// Returns false, with no completion to follow, when the interface has no string or the + /// read could not be started. + bool read_interface_string_(uint8_t interface_number); + /// The string read_interface_string_() asked for, or null when the read failed. It lives + /// in the transfer buffer only until the next read, so copy what is needed. + virtual void on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) {} + const usb_str_desc_t *string_result_() const; + static void string_transfer_callback(usb_transfer_t *xfer); TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) + /// Must not be called while a control transfer is in flight: the host stack asserts on + /// closing a device with one pending, and has no way to cancel it virtual void disconnect(); virtual void on_connected() {} @@ -220,6 +242,8 @@ class USBClient : public Component { // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; + // Reads iInterface strings: a string can outgrow the packet-sized pool buffers + usb_transfer_t *string_transfer_{nullptr}; TaskHandle_t usb_task_handle_{nullptr}; usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; @@ -232,8 +256,16 @@ class USBClient : public Component { std::atomic trq_in_use_; const char16_t *manufacturer_filter_{nullptr}; const char16_t *product_filter_{nullptr}; + const char16_t *interface_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; + // Set by the USB task when the string transfer completes, cleared by the main loop + std::atomic string_done_{0}; + // The interface whose string was last asked for + uint8_t string_interface_{0}; + // The next interface the filter pass reads, and whether one has matched the filter so far + uint8_t filter_interface_{0}; + bool interface_matched_{false}; // Whether the connection callbacks were told about the current device, so a removal is // only ever reported for a device that was reported connected bool connection_reported_{false}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 1b50aa75baf..254689a7a70 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -147,14 +147,15 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc #endif // bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType), // so character count = (bLength - 2) / 2. -bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { buffer[0] = '\0'; if (desc == nullptr || desc->bLength < 2) return true; - int char_count = (desc->bLength - 2) / 2; + const size_t char_count = (desc->bLength - 2) / 2; + if (char_count >= buffer.size()) + return false; char *p = buffer.data(); - char *end = p + buffer.size() - 1; - for (int i = 0; i != char_count && p < end; i++) { + for (size_t i = 0; i != char_count; i++) { auto c = desc->wData[i]; // TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up if (c >= 0x80) { @@ -287,9 +288,17 @@ void USBClient::setup() { // Pre-allocate USB transfer buffers for all slots at startup // This avoids any dynamic allocation during runtime for (auto &request : this->requests_) { - usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer); + if (usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer) != ESP_OK) { + this->mark_failed(LOG_STR("Transfer alloc failed")); + return; + } request.client = this; // Set once, never changes } + if (usb_host_transfer_alloc(STRING_TRANSFER_SIZE, 0, &this->string_transfer_) != ESP_OK) { + this->mark_failed(LOG_STR("Transfer alloc failed")); + return; + } + this->string_transfer_->context = this; // Create and start USB task xTaskCreate(usb_task_fn, "usb_task", @@ -342,6 +351,19 @@ bool USBClient::process_usb_events_() { if (this->state_ == USB_CLIENT_OPEN) { had_work = true; this->handle_open_state_(); + } else if (this->string_done_.load(std::memory_order_acquire)) { + // Acquire: pairs with the release in string_transfer_callback() + had_work = true; + this->string_done_.store(0); + // The device may have gone away while the read was in flight; then nobody is waiting + if (this->state_ == USB_CLIENT_GET_DESC) { + const usb_str_desc_t *desc = this->string_result_(); + if (desc != nullptr && descriptor_string_equals(desc, this->interface_filter_)) + this->interface_matched_ = true; + this->handle_get_desc_state_(); + } else if (this->state_ == USB_CLIENT_CONNECTED) { + this->on_interface_string(this->string_interface_, this->string_result_()); + } } return had_work; @@ -397,7 +419,52 @@ void USBClient::handle_open_state_() { this->disconnect(); return; } + if (this->interface_filter_ == nullptr) { + this->finish_open_(); + return; + } + // The interface strings are not cached by the host stack, so the filter reads them itself + this->state_ = USB_CLIENT_GET_DESC; + this->interface_matched_ = false; + this->filter_interface_ = 0; + this->handle_get_desc_state_(); +} + +// The filter pass: reads the iInterface string of each interface in turn until one matches the +// filter, then finishes opening the device or closes it. Runs on the main +// loop, from handle_open_state_() first and then after each string. +void USBClient::handle_get_desc_state_() { + const usb_config_desc_t *config_desc; + esp_err_t err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Config descriptor query failed: %s", esp_err_to_name(err)); + this->disconnect(); + return; + } + while (!this->interface_matched_ && this->filter_interface_ < config_desc->bNumInterfaces) { + if (this->read_interface_string_(this->filter_interface_++)) + return; + } + if (!this->interface_matched_) { + ESP_LOGD(TAG, "Device does not match interface filter, closing"); + this->disconnect(); + return; + } + this->finish_open_(); +} + +// The device passed every filter: hand it to the subclass +void USBClient::finish_open_() { + usb_device_info_t dev_info; + esp_err_t err = usb_host_device_info(this->device_handle_, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err)); + this->disconnect(); + return; + } this->state_ = USB_CLIENT_CONNECTED; + char buf_manuf[DESC_STRING_BUF_SIZE]; + char buf_product[DESC_STRING_BUF_SIZE]; char buf_serial[DESC_STRING_BUF_SIZE]; ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s", get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), @@ -421,6 +488,61 @@ void USBClient::handle_open_state_() { } } +bool USBClient::read_interface_string_(uint8_t interface_number) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + if (usb_host_get_active_config_descriptor(this->device_handle_, &config_desc) != ESP_OK || + usb_host_get_device_descriptor(this->device_handle_, &device_desc) != ESP_OK) { + ESP_LOGW(TAG, "Descriptor query failed"); + return false; + } + int offset = 0; + const usb_intf_desc_t *intf = usb_parse_interface_descriptor(config_desc, interface_number, 0, &offset); + if (intf == nullptr || intf->iInterface == 0) + return false; + usb_transfer_t *xfer = this->string_transfer_; + USB_SETUP_PACKET_INIT_GET_STR_DESC(reinterpret_cast(xfer->data_buffer), intf->iInterface, + STRING_DESC_LANGID, STRING_DESC_MAX_SIZE); + xfer->device_handle = this->device_handle_; + xfer->bEndpointAddress = USB_DIR_IN; + // The data stage is read in whole packets, as the host stack's own descriptor reads are + xfer->num_bytes = SETUP_PACKET_SIZE + usb_round_up_to_mps(STRING_DESC_MAX_SIZE, device_desc->bMaxPacketSize0); + xfer->callback = string_transfer_callback; + esp_err_t err = usb_host_transfer_submit_control(this->handle_, xfer); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Interface %u string read failed: %s", interface_number, esp_err_to_name(err)); + return false; + } + this->string_interface_ = interface_number; + return true; +} + +// The reply to the last read_interface_string_(), when it is a well-formed string descriptor +const usb_str_desc_t *USBClient::string_result_() const { + const usb_transfer_t *xfer = this->string_transfer_; + // actual_num_bytes counts the setup packet too + const size_t received = xfer->actual_num_bytes > SETUP_PACKET_SIZE ? xfer->actual_num_bytes - SETUP_PACKET_SIZE : 0; + const auto *desc = reinterpret_cast(xfer->data_buffer + SETUP_PACKET_SIZE); + if (xfer->status != USB_TRANSFER_STATUS_COMPLETED || received < USB_STR_DESC_SIZE || + desc->bDescriptorType != USB_B_DESCRIPTOR_TYPE_STRING || desc->bLength > received) { + ESP_LOGW(TAG, "Interface %u string read failed: status %d, %zu bytes", this->string_interface_, xfer->status, + received); + return nullptr; + } + char buf[DESC_STRING_BUF_SIZE]; + ESP_LOGD(TAG, "Interface %u: %s", this->string_interface_, get_descriptor_string(desc, buf)); + return desc; +} + +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) +void USBClient::string_transfer_callback(usb_transfer_t *xfer) { + auto *client = static_cast(xfer->context); + // Release: publishes the transfer result before the loop observes string_done_ + client->string_done_.store(1, std::memory_order_release); + client->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); +} + void USBClient::report_connected_() { if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) { return; @@ -509,6 +631,7 @@ void USBClient::disconnect() { this->state_ = USB_CLIENT_INIT; this->device_handle_ = nullptr; this->device_addr_ = -1; + this->string_done_.store(0); if (was_reported) { this->connection_callback_.call(false); } @@ -662,6 +785,9 @@ void USBClient::dump_config() { if (this->product_filter_ != nullptr) { ESP_LOGCONFIG(TAG, " Product %s", filter_string(this->product_filter_, buf)); } + if (this->interface_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Interface %s", filter_string(this->interface_filter_, buf)); + } } // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediately after transfer callback completes diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index ebba760434c..9a5e2f31a26 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -392,7 +392,6 @@ void USBUartTypeCdcAcm::on_connected() { channel->cdc_dev_ = cdc_devs[i++]; fix_mps(channel->cdc_dev_.in_ep); fix_mps(channel->cdc_dev_.out_ep); - channel->initialised_.store(true); // Claim the communication (interrupt) interface so CDC class requests are accepted // by the device. Some CDC ACM implementations (e.g. EFR32 NCP) require this before // they enable data flow on the bulk endpoints. @@ -424,11 +423,13 @@ void USBUartTypeCdcAcm::on_connected() { } } this->status_clear_error(); - this->enable_channels(); + this->read_interface_descriptions_(); } void USBUartTypeCdcAcm::on_disconnected() { + this->string_channel_ = 0; for (auto *channel : this->channels_) { + channel->interface_description_[0] = '\0'; // Not set up for this device: it was rejected before on_connected() ran, or it has // fewer ports than there are channels. Nothing was claimed for it. if (channel->cdc_dev_.in_ep == nullptr) @@ -520,6 +521,31 @@ void USBUartTypeCdcAcm::modem_control_transfer(USBUartChannelBase *channel) { channel->cdc_dev_.interrupt_interface_number); } +void USBUartComponent::read_interface_descriptions_() { + while (this->string_channel_ < this->channels_.size()) { + auto *channel = this->channels_[this->string_channel_]; + if (channel->cdc_dev_.in_ep != nullptr && this->read_interface_string_(channel->get_interface_number())) + return; + this->string_channel_++; + } + this->string_channel_ = 0; + // Mark the channels initialised after the string reads, not in on_connected(): a settings or + // modem transfer started during the reads would be cut off by the init config + for (auto *channel : this->channels_) { + if (channel->cdc_dev_.in_ep != nullptr) + channel->initialised_.store(true); + } + this->enable_channels(); +} + +void USBUartComponent::on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) { + auto *channel = this->channels_[this->string_channel_++]; + if (!usb_host::copy_descriptor_string(desc, channel->interface_description_)) { + ESP_LOGW(TAG, "Interface %u string descriptor is not ASCII", interface_number); + } + this->read_interface_descriptions_(); +} + void USBUartComponent::enable_channels() { this->cfg_single_ = nullptr; // Init sends every channel's current line settings and DTR/RTS diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 443dfc388fc..f2654e491e8 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -186,6 +186,10 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedinterface_description_; } + /// Store DTR/RTS and send them to the device. False when not connected. bool set_modem_control(bool dtr, bool rts); bool get_dtr() const { return this->dtr_; } @@ -198,6 +202,7 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_buffer_; LockFreeQueue output_queue_; @@ -296,6 +301,11 @@ class USBUartComponent : public usb_host::USBClient { // settings, so the connected report waits for run_config_machine_() to finish the init bool reports_connection_itself() const override { return true; } + // Copy each channel's interface string, one read at a time, then enable the channels. + // Called at the end of on_connected(); continues in on_interface_string(). + void read_interface_descriptions_(); + void on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) override; + std::vector channels_{}; // Config state machine @@ -307,6 +317,7 @@ class USBUartComponent : public usb_host::USBClient { uint8_t cfg_response_[8]{}; // last IN transfer payload uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; + uint8_t string_channel_{0}; // the channel whose interface string is being read ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT}; bool cfg_active_{false}; bool cfg_device_phase_{false}; diff --git a/esphome/components/vevor_dtx/vevor_dtx.cpp b/esphome/components/vevor_dtx/vevor_dtx.cpp index 364a6ba914f..9508745787e 100644 --- a/esphome/components/vevor_dtx/vevor_dtx.cpp +++ b/esphome/components/vevor_dtx/vevor_dtx.cpp @@ -65,7 +65,7 @@ bool VevorDtxComponent::on_receive(remote_base::RemoteReceiveData data) { // through a register until the sync word appears, then the payload is collected and its checksum tested. // A run is never treated as a gap, since a payload may hold long runs of zeros; a gap simply shifts zeros // through the register, and one longer than a frame is cut to a frame's worth, which has the same effect. -bool VevorDtxComponent::decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const { +bool VevorDtxComponent::decode_timings_(remote_base::RawTimingsView timings, Payload &payload) const { uint64_t stream = 0; uint16_t collected = 0; // payload bits gathered since the sync word, 0 while still searching for it diff --git a/esphome/components/vevor_dtx/vevor_dtx.h b/esphome/components/vevor_dtx/vevor_dtx.h index 56eea99415d..0ecc0f27456 100644 --- a/esphome/components/vevor_dtx/vevor_dtx.h +++ b/esphome/components/vevor_dtx/vevor_dtx.h @@ -53,7 +53,7 @@ class VevorDtxComponent : public Component, public remote_base::RemoteReceiverLi using Payload = std::array; - bool decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const; + bool decode_timings_(remote_base::RawTimingsView timings, Payload &payload) const; bool publish_(const Payload &payload); uint16_t station_id_{0}; // 0 accepts every station in range diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 4ab389da784..94b9d712481 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv from esphome.const import ( + CONF_BUFFER_DURATION, CONF_ID, CONF_MEDIA_PLAYER, CONF_MICROPHONE, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_ON_IDLE, CONF_ON_START, CONF_SPEAKER, + CONF_TIMEOUT, ) from esphome.types import ConfigType @@ -34,6 +36,7 @@ CONF_ON_TTS_START = "on_tts_start" CONF_ON_TTS_STREAM_START = "on_tts_stream_start" CONF_ON_TTS_STREAM_END = "on_tts_stream_end" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_ON_WAKE_WORD_REJECTED = "on_wake_word_rejected" CONF_SILENCE_DETECTION = "silence_detection" CONF_USE_WAKE_WORD = "use_wake_word" @@ -45,6 +48,7 @@ CONF_VOLUME_MULTIPLIER = "volume_multiplier" CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" +CONF_WAKE_WORD_AUDIO = "wake_word_audio" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" @@ -92,6 +96,21 @@ CONFIG_SCHEMA = cv.All( cv.Exclusive(CONF_SPEAKER, "output"): cv.use_id(speaker.Speaker), cv.Optional(CONF_USE_WAKE_WORD, default=False): cv.boolean, cv.Optional(CONF_MICRO_WAKE_WORD): cv.use_id(micro_wake_word.MicroWakeWord), + cv.Optional(CONF_WAKE_WORD_AUDIO): cv.Schema( + { + cv.Optional(CONF_BUFFER_DURATION, default="1500ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=core.TimePeriod(milliseconds=100), + max=core.TimePeriod(seconds=5), + ), + ), + cv.Optional(CONF_TIMEOUT, default="2s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), + } + ), cv.Optional(CONF_VAD_THRESHOLD): cv.invalid( "VAD threshold is no longer supported, as it requires the deprecated esp_adf external component. Use an i2s_audio microphone/speaker instead. Additionally, you may need to configure the audio_adc and audio_dac components depending on your hardware." ), @@ -118,6 +137,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_WAKE_WORD_REJECTED): automation.validate_automation( + single=True + ), cv.Optional(CONF_ON_STT_END): automation.validate_automation(single=True), cv.Optional(CONF_ON_TTS_START): automation.validate_automation(single=True), cv.Optional(CONF_ON_TTS_END): automation.validate_automation(single=True), @@ -169,6 +191,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.key_requires((CONF_ON_TTS_STREAM_START, CONF_ON_TTS_STREAM_END), CONF_SPEAKER), + cv.key_requires(CONF_WAKE_WORD_AUDIO, CONF_MICRO_WAKE_WORD), + cv.key_requires(CONF_ON_WAKE_WORD_REJECTED, CONF_WAKE_WORD_AUDIO), ) FINAL_VALIDATE_SCHEMA = cv.All( @@ -203,6 +227,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) + if wake_word_audio := config.get(CONF_WAKE_WORD_AUDIO): + cg.add_define("USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO") + cg.add( + var.set_wake_word_audio( + wake_word_audio[CONF_BUFFER_DURATION], wake_word_audio[CONF_TIMEOUT] + ) + ) + cg.add_optional(var.set_vad_threshold, config.get(CONF_VAD_THRESHOLD)) cg.add(var.set_noise_suppression_level(config[CONF_NOISE_SUPPRESSION_LEVEL])) @@ -231,6 +263,13 @@ async def to_code(config: ConfigType) -> None: config[CONF_ON_WAKE_WORD_DETECTED], ) + if CONF_ON_WAKE_WORD_REJECTED in config: + await automation.build_automation( + var.get_wake_word_rejected_trigger(), + [], + config[CONF_ON_WAKE_WORD_REJECTED], + ) + if CONF_ON_STT_END in config: await automation.build_automation( var.get_stt_end_trigger(), [(cg.std_string, "x")], config[CONF_ON_STT_END] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 7ea22d756d2..d82350efbb4 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -18,6 +18,7 @@ static constexpr uint32_t RESET_CONVERSATION_TIMEOUT_ID = 0; static constexpr uint32_t PLAYBACK_TIMEOUT_ID = 1; static constexpr uint32_t SPEAKER_IDLE_TIMEOUT_ID = 2; static constexpr uint32_t TIMER_TICK_INTERVAL_ID = 3; +static constexpr uint32_t WAKE_WORD_VERDICT_TIMEOUT_ID = 4; #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ @@ -41,6 +42,31 @@ static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; // SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks. static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +// Room for live audio while the buffered audio is sent +static constexpr uint32_t WAKE_WORD_AUDIO_HEADROOM_MS = 1000; +static constexpr uint32_t WAKE_WORD_DETECTION_MAX_AGE_MS = 1000; + +static size_t trim_audio_source(audio::RingBufferAudioSource *source, + const std::weak_ptr &weak_ring_buffer, size_t target_bytes) { + std::shared_ptr ring_buffer = weak_ring_buffer.lock(); + if ((source == nullptr) || (ring_buffer == nullptr)) { + return 0; + } + while (true) { + source->fill(0); + const size_t exposed = source->available(); + const size_t buffered = exposed + ring_buffer->available(); + // Whole samples only + const size_t excess = (buffered > target_bytes) ? ((buffered - target_bytes) & ~(sizeof(int16_t) - 1)) : 0; + if ((excess == 0) || (exposed == 0)) { + return buffered; + } + source->consume(std::min(exposed, excess)); + } +} +#endif + VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { @@ -61,6 +87,16 @@ void VoiceAssistant::setup() { }); } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->micro_wake_word_->add_on_wake_word_detected_callback([this](const micro_wake_word::DetectionEvent &event) { + constexpr float uint8_to_float_divisor = 255.0f; + this->detection_.id = (event.id != nullptr) ? *event.id : ""; + this->detection_.probability = event.average_probability / uint8_to_float_divisor; + this->detection_.probability_cutoff = event.probability_cutoff / uint8_to_float_divisor; + this->detection_.detected_at = millis(); + }); +#endif + #ifdef USE_MEDIA_PLAYER if (this->media_player_ != nullptr) { this->media_player_->add_on_state_callback([this](media_player::MediaPlayerState state) { @@ -145,7 +181,8 @@ bool VoiceAssistant::allocate_buffers_() { #endif if (this->audio_source_ == nullptr) { - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + std::shared_ptr temp_ring_buffer = + ring_buffer::RingBuffer::create(this->ring_buffer_size_()); if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; @@ -161,7 +198,8 @@ bool VoiceAssistant::allocate_buffers_() { // Second microphone channel if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) { - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + std::shared_ptr temp_ring_buffer = + ring_buffer::RingBuffer::create(this->ring_buffer_size_()); if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate second ring buffer"); return false; @@ -177,13 +215,23 @@ bool VoiceAssistant::allocate_buffers_() { return true; } -void VoiceAssistant::clear_buffers_() { - if (this->audio_source_ != nullptr) { +size_t VoiceAssistant::ring_buffer_size_() const { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->is_wake_word_audio_active()) { + return (this->wake_word_audio_buffer_duration_ + WAKE_WORD_AUDIO_HEADROOM_MS) * SAMPLE_RATE_HZ / 1000 * + sizeof(int16_t); + } +#endif + return RING_BUFFER_SIZE; +} + +void VoiceAssistant::clear_buffers_(bool clear_microphone_audio) { + if (clear_microphone_audio && (this->audio_source_ != nullptr)) { this->audio_source_->clear_buffered_data(); } // Second microphone channel - if (this->audio_source2_ != nullptr) { + if (clear_microphone_audio && (this->audio_source2_ != nullptr)) { this->audio_source2_->clear_buffered_data(); } @@ -313,13 +361,43 @@ void VoiceAssistant::loop() { this->continuous_ = false; this->signal_stop_(); this->clear_buffers_(); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif return; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->update_wake_word_audio_(); +#endif switch (this->state_) { case State::IDLE: { if (this->continuous_ && this->desired_state_ == State::IDLE) { this->idle_trigger_.trigger(); this->set_state_(State::START_MICROPHONE, State::START_PIPELINE); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + } else if (this->wake_word_audio_buffering_) { + // Only microWakeWord detections use the buffered audio, so the microphone idles while it is stopped + // (a software mute, say); the buffers stay allocated for when it runs again. + if (this->micro_wake_word_->is_running()) { + // Until the microphone runs again, drop what it buffered before it stopped, so a clip never joins audio + // from before a mute to audio after it + if (!this->mic_source_->is_running() && (this->audio_source_ != nullptr)) { + this->audio_source_->clear_buffered_data(); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); + } + } + this->mic_source_->start(); + if (this->mic_source2_) { + this->mic_source2_->start(); + } + } else { + this->mic_source_->stop(); + if (this->mic_source2_) { + this->mic_source2_->stop(); + } + } +#endif } else { this->deallocate_buffers_(); } @@ -334,7 +412,11 @@ void VoiceAssistant::loop() { if (this->status_has_error()) { this->status_clear_error(); } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_buffers_(!this->wake_word_audio_buffering_); +#else this->clear_buffers_(); +#endif this->mic_source_->start(); if (this->mic_source2_) { @@ -367,6 +449,30 @@ void VoiceAssistant::loop() { msg.flags = flags; msg.audio_settings = std::move(audio_settings); msg.wake_word_phrase = StringRef(this->wake_word_); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_) { + const size_t bytes_per_ms = SAMPLE_RATE_HZ / 1000 * sizeof(int16_t); + // Audio from after the detection belongs to the command, not the wake word + const size_t command_bytes = + this->start_with_detection_ ? (millis() - this->detection_.detected_at) * bytes_per_ms : 0; + const size_t target_bytes = + this->start_with_detection_ ? (this->wake_word_audio_buffer_duration_ * bytes_per_ms) + command_bytes : 0; + const size_t buffered = trim_audio_source(this->audio_source_.get(), this->ring_buffer_, target_bytes); + const size_t buffered2 = trim_audio_source(this->audio_source2_.get(), this->ring_buffer2_, target_bytes); + if (this->start_with_detection_) { + const size_t wake_word_bytes = buffered - std::min(buffered, command_bytes); + const size_t wake_word_bytes2 = buffered2 - std::min(buffered2, command_bytes); + msg.wake_word_id = StringRef(this->detection_.id); + msg.wake_word_audio_bytes = wake_word_bytes; + msg.wake_word_audio_bytes2 = wake_word_bytes2; + msg.wake_word_probability = this->detection_.probability; + msg.wake_word_probability_cutoff = this->detection_.probability_cutoff; + ESP_LOGD(TAG, "Sending %zu/%zu bytes of wake word audio for %s", wake_word_bytes, wake_word_bytes2, + this->detection_.id.c_str()); + } + } + this->detection_.detected_at = 0; +#endif // Reset media player state tracking #ifdef USE_MEDIA_PLAYER @@ -382,9 +488,26 @@ void VoiceAssistant::loop() { this->set_state_(State::IDLE, State::IDLE); break; } - this->set_state_(State::STARTING_PIPELINE); this->set_timeout(RESET_CONVERSATION_TIMEOUT_ID, this->conversation_timeout_, [this]() { this->reset_conversation_id(); }); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->streaming_before_response_ = this->start_with_detection_; + if (this->start_with_detection_) { + this->start_with_detection_ = false; + // Waiting for the response would let live audio overwrite the buffered audio + ESP_LOGD(TAG, "Streaming microphone"); + this->audio_mode_ = AUDIO_MODE_API; + this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE); + this->set_timeout(WAKE_WORD_VERDICT_TIMEOUT_ID, this->wake_word_audio_timeout_, [this]() { + ESP_LOGW(TAG, "Wake word was not accepted or rejected in time"); + this->request_stop(); + this->error_trigger_.trigger("wake-word-verification-timeout", + "Wake word was not accepted or rejected in time"); + }); + break; + } +#endif + this->set_state_(State::STARTING_PIPELINE); break; } case State::STARTING_PIPELINE: { @@ -416,6 +539,12 @@ void VoiceAssistant::loop() { break; } case State::STOP_MICROPHONE: { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_) { + this->set_state_(this->desired_state_); + break; + } +#endif // Check both microphone channels bool is_running = this->mic_source_->is_running(); bool is_running2 = false; @@ -579,13 +708,16 @@ void VoiceAssistant::write_speaker_() { } #endif -void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscribe) { +void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscribe, uint32_t flags) { if (!subscribe) { if (this->api_client_ == nullptr || client != this->api_client_) { ESP_LOGE(TAG, "Client attempting to unsubscribe that is not the current API Client"); return; } this->api_client_ = nullptr; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->wake_word_audio_requested_ = false; +#endif this->client_disconnected_trigger_.trigger(); return; } @@ -603,6 +735,13 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr } this->api_client_ = client; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->wake_word_audio_requested_ = (flags & api::enums::VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO) != 0; + ESP_LOGD(TAG, "Wake word audio %s", + this->wake_word_audio_requested_ ? LOG_STR_LITERAL("requested") : LOG_STR_LITERAL("not requested")); + // So is_wake_word_audio_active() reflects a failed allocation in on_client_connected + this->update_wake_word_audio_(); +#endif this->client_connected_trigger_.trigger(); } @@ -639,11 +778,24 @@ static const LogString *voice_assistant_state_to_string(State state) { } }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +static bool is_microphone_streaming_state(State state) { + return (state == State::START_PIPELINE) || (state == State::STARTING_PIPELINE) || + (state == State::STREAMING_MICROPHONE); +} +#endif + void VoiceAssistant::set_state_(State state) { State old_state = this->state_; this->state_ = state; ESP_LOGD(TAG, "State changed from %s to %s", LOG_STR_ARG(voice_assistant_state_to_string(old_state)), LOG_STR_ARG(voice_assistant_state_to_string(state))); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_ && is_microphone_streaming_state(old_state) && + !is_microphone_streaming_state(state)) { + this->end_wake_word_audio_stream_(); + } +#endif } void VoiceAssistant::set_state_(State state, State desired_state) { @@ -669,6 +821,12 @@ void VoiceAssistant::failed_to_start() { } void VoiceAssistant::start_streaming() { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->streaming_before_response_) { + this->streaming_before_response_ = false; + return; + } +#endif if (this->state_ != State::STARTING_PIPELINE) { this->signal_stop_(); return; @@ -733,6 +891,13 @@ void VoiceAssistant::request_start(bool continuous, bool silence_detection) { if (this->state_ == State::IDLE) { this->continuous_ = continuous; this->silence_detection_ = silence_detection; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->start_with_detection_ = this->wake_word_audio_buffering_ && (this->detection_.detected_at != 0) && + ((millis() - this->detection_.detected_at) <= WAKE_WORD_DETECTION_MAX_AGE_MS); + if (!this->start_with_detection_) { + this->detection_.detected_at = 0; + } +#endif this->set_state_(State::START_MICROPHONE, State::START_PIPELINE); } @@ -741,6 +906,11 @@ void VoiceAssistant::request_start(bool continuous, bool silence_detection) { void VoiceAssistant::request_stop() { this->continuous_ = false; this->continue_conversation_ = false; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->start_with_detection_ = false; + this->detection_.detected_at = 0; + this->clear_wake_word_verdict_(); +#endif switch (this->state_) { case State::IDLE: @@ -850,9 +1020,23 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { break; case api::enums::VOICE_ASSISTANT_WAKE_WORD_END: { ESP_LOGD(TAG, "Wake word detected"); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif this->defer([this]() { this->wake_word_detected_trigger_.trigger(); }); break; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + case api::enums::VOICE_ASSISTANT_WAKE_WORD_REJECTED: { + ESP_LOGD(TAG, "Wake word rejected"); + this->clear_wake_word_verdict_(); + if ((this->state_ == State::STARTING_PIPELINE) || (this->state_ == State::STREAMING_MICROPHONE)) { + this->set_state_(State::STOP_MICROPHONE, State::IDLE); + } + this->defer([this]() { this->wake_word_rejected_trigger_.trigger(); }); + break; + } +#endif case api::enums::VOICE_ASSISTANT_STT_START: ESP_LOGD(TAG, "STT started"); this->defer([this]() { this->listening_trigger_.trigger(); }); @@ -976,6 +1160,9 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { } case api::enums::VOICE_ASSISTANT_RUN_END: { ESP_LOGD(TAG, "Assist Pipeline ended"); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif if ((this->state_ == State::START_PIPELINE) || (this->state_ == State::STARTING_PIPELINE) || (this->state_ == State::STREAMING_MICROPHONE)) { // Microphone is running, stop it @@ -1012,6 +1199,9 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { return; } ESP_LOGE(TAG, "Error: %s - %s", code.c_str(), message.c_str()); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif if (this->state_ != State::IDLE) { this->signal_stop_(); this->set_state_(State::STOP_MICROPHONE, State::IDLE); @@ -1215,6 +1405,60 @@ const Configuration &VoiceAssistant::get_configuration() { return this->config_; }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +void VoiceAssistant::update_wake_word_audio_() { + const bool active = this->is_wake_word_audio_active(); + if (active == this->wake_word_audio_buffering_) { + return; + } + + if (active) { + if (this->state_ != State::IDLE) { + // Retried every loop, so the buffers are resized once the run ends + return; + } + // Buffers left over from a run without wake word audio are too small + this->deallocate_buffers_(); + if (!this->allocate_buffers_()) { + // Detect wake words without verification rather than retry large allocations every loop + ESP_LOGE(TAG, "Not enough memory for wake word audio"); + this->wake_word_audio_requested_ = false; + return; + } + // The IDLE branch of loop() starts the microphone while microWakeWord runs + ESP_LOGD(TAG, "Buffering wake word audio"); + } else { + this->clear_wake_word_verdict_(); + if (this->state_ == State::IDLE) { + this->mic_source_->stop(); + if (this->mic_source2_) { + this->mic_source2_->stop(); + } + } + ESP_LOGD(TAG, "Stopped buffering wake word audio"); + } + this->wake_word_audio_buffering_ = active; + +#ifdef USE_MICRO_WAKE_WORD_VAD + // The VAD model blocks true detections at the lower cutoffs + this->micro_wake_word_->set_vad_enabled(!active); +#endif +} + +void VoiceAssistant::clear_wake_word_verdict_() { this->cancel_timeout(WAKE_WORD_VERDICT_TIMEOUT_ID); } + +void VoiceAssistant::end_wake_word_audio_stream_() { + // A chunk still held by an audio source stops its ring buffer from replacing old audio + if (this->audio_source_ != nullptr) { + this->audio_source_->clear_buffered_data(); + } + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); + } + this->clear_wake_word_verdict_(); +} +#endif + VoiceAssistant *global_voice_assistant = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::voice_assistant diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 75e382543b3..ec66e5f5e9d 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -42,6 +42,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_ANNOUNCE = 1 << 4, FEATURE_START_CONVERSATION = 1 << 5, FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, + FEATURE_WAKE_WORD_AUDIO = 1 << 7, }; enum class State : uint8_t { @@ -89,6 +90,16 @@ struct WakeWord { std::vector trained_languages; }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +struct WakeWordDetection { + std::string id; + float probability; + float probability_cutoff; + // 0 once used + uint32_t detected_at{0}; +}; +#endif + struct Configuration { std::vector available_wake_words; std::vector active_wake_words; @@ -149,6 +160,9 @@ class VoiceAssistant final : public Component { if (this->mic_source2_ != nullptr) { flags |= VoiceAssistantFeature::FEATURE_MULTI_CHANNEL_AUDIO; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + flags |= VoiceAssistantFeature::FEATURE_WAKE_WORD_AUDIO; +#endif #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { flags |= VoiceAssistantFeature::FEATURE_SPEAKER; @@ -187,6 +201,20 @@ class VoiceAssistant final : public Component { void set_use_wake_word(bool use_wake_word) { this->use_wake_word_ = use_wake_word; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + void set_wake_word_audio(uint32_t buffer_duration_ms, uint32_t timeout_ms) { + this->wake_word_audio_buffer_duration_ = buffer_duration_ms; + this->wake_word_audio_timeout_ = timeout_ms; + } +#endif + bool is_wake_word_audio_active() const { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + return this->wake_word_audio_requested_ && (this->api_client_ != nullptr); +#else + return false; +#endif + } + void set_noise_suppression_level(uint8_t noise_suppression_level) { this->noise_suppression_level_ = noise_suppression_level; } @@ -209,6 +237,9 @@ class VoiceAssistant final : public Component { Trigger<> *get_tts_stream_end_trigger() { return &this->tts_stream_end_trigger_; } #endif Trigger<> *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + Trigger<> *get_wake_word_rejected_trigger() { return &this->wake_word_rejected_trigger_; } +#endif Trigger *get_stt_end_trigger() { return &this->stt_end_trigger_; } Trigger *get_tts_end_trigger() { return &this->tts_end_trigger_; } Trigger *get_tts_start_trigger() { return &this->tts_start_trigger_; } @@ -218,7 +249,7 @@ class VoiceAssistant final : public Component { Trigger<> *get_client_connected_trigger() { return &this->client_connected_trigger_; } Trigger<> *get_client_disconnected_trigger() { return &this->client_disconnected_trigger_; } - void client_subscription(api::APIConnection *client, bool subscribe); + void client_subscription(api::APIConnection *client, bool subscribe, uint32_t flags = 0); api::APIConnection *get_api_connection() const { return this->api_client_; } void set_wake_word(const std::string &wake_word) { this->wake_word_ = wake_word; } @@ -233,8 +264,15 @@ class VoiceAssistant final : public Component { protected: bool allocate_buffers_(); - void clear_buffers_(); + void clear_buffers_(bool clear_microphone_audio = true); void deallocate_buffers_(); + size_t ring_buffer_size_() const; + +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + void update_wake_word_audio_(); + void clear_wake_word_verdict_(); + void end_wake_word_audio_stream_(); +#endif void set_state_(State state); void set_state_(State state, State desired_state); @@ -268,6 +306,9 @@ class VoiceAssistant final : public Component { #endif Trigger intent_progress_trigger_; Trigger<> wake_word_detected_trigger_; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + Trigger<> wake_word_rejected_trigger_; +#endif Trigger stt_end_trigger_; Trigger tts_end_trigger_; Trigger tts_start_trigger_; @@ -332,6 +373,12 @@ class VoiceAssistant final : public Component { micro_wake_word::MicroWakeWord *micro_wake_word_{nullptr}; #endif +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + WakeWordDetection detection_{}; + uint32_t wake_word_audio_buffer_duration_{0}; + uint32_t wake_word_audio_timeout_{0}; +#endif + // 1 byte members grouped at the end so they share padding instead of each taking a word State state_{State::IDLE}; State desired_state_{State::IDLE}; @@ -354,6 +401,12 @@ class VoiceAssistant final : public Component { bool silence_detection_; bool continue_conversation_{false}; bool udp_socket_running_{false}; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + bool wake_word_audio_requested_{false}; + bool wake_word_audio_buffering_{false}; + bool start_with_detection_{false}; + bool streaming_before_response_{false}; +#endif }; extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 48671b780d3..5f25750c731 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -111,7 +111,7 @@ bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1), LOG_STR("Failed to read input register"))) return false; - this->input_mask_ = data; + this->input_[0] = data; return true; } @@ -126,7 +126,6 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { LOG_STR("Failed to write output register")); } -bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } } // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h index ed11b72e078..36e62d1475c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -8,9 +8,10 @@ namespace esphome::waveshare_io_ch32v003 { -class WaveshareIOCH32V003Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class WaveshareIOCH32V003Component + : public Component, + public gpio_expander::GpioExpanderInputs>, + public i2c::I2CDevice { public: WaveshareIOCH32V003Component() = default; @@ -31,12 +32,10 @@ class WaveshareIOCH32V003Component : public Component, protected: bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW - uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW bool write_gpio_modes_(); bool write_gpio_outputs_(); diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 0d550daa153..d11b68e59a8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -10,7 +10,10 @@ import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.json import enable_arena from esphome.components.logger import request_log_listener -from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.components.web_server_base import ( + CONF_WEB_SERVER_BASE_ID, + MAX_ORIGIN_LENGTH, +) import esphome.config_validation as cv # Re-exported so entity components and external components keep importing @@ -163,6 +166,11 @@ def validate_origin(value: Any) -> str: f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." ) + if len(value) > MAX_ORIGIN_LENGTH: + raise cv.Invalid( + f"'{value}' is too long; an allowed origin can have at most " + f"{MAX_ORIGIN_LENGTH} characters." + ) # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. return value.lower() @@ -541,11 +549,16 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") - if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + allowed_origins = config.get(CONF_ALLOWED_ORIGINS, []) + if "*" in allowed_origins: + # Any origin is allowed, so the check compiles to nothing and no table is stored. + cg.add_define("USE_WEBSERVER_ALLOW_ANY_ORIGIN") + elif allowed_origins: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") # Shared flash table ended by nullptr, so the server stores only a pointer. + # Set on web_server_base so every handler added through add_handler() uses it. cg.add( - var.set_allowed_origins( + paren.set_allowed_origins( cg.shared_progmem_array( "web_server_allowed_origins", cg.const_char_ptr, diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index dcf8a448fa4..84723878fa2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -529,62 +529,23 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif -// Read a request header value portably across the Arduino and ESP-IDF web servers. -// Returns an empty string when the header is absent (only allocates when a value is present). -static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { -#ifdef USE_ESP32 - // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. - optional value = request->get_header(name); - return value.has_value() ? std::move(*value) : std::string(); -#else - // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. - const AsyncWebHeader *header = request->getHeader(name); - return header != nullptr ? std::string(header->value().c_str()) : std::string(); -#endif -} - -bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { - // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. - if (origin.empty()) - return true; - - // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. - // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. - const size_t scheme_sep = origin.find("://"); - if (scheme_sep != std::string::npos) { - const std::string host = get_request_header(request, "Host"); - // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here - const size_t authority = scheme_sep + 3; - if (!host.empty() && origin.size() - authority == host.size() && - memcmp(origin.data() + authority, host.data(), host.size()) == 0) - return true; - } - -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - // Otherwise the origin must be explicitly allowed via configuration. - for (const char *const *it = this->allowed_origins_; *it != nullptr; it++) { - const char *allowed_origin = *it; - // A single "*" entry allows any origin. - if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') - return true; - if (origin == allowed_origin) - return true; - } -#endif - return false; -} - #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { - const std::string origin = get_request_header(request, "Origin"); - if (!this->is_request_origin_allowed_(request, origin)) { - request->send(403); - return; - } - + // WebServerBase's middleware already rejected an origin that is not allowed. +#ifdef USE_ESP32 + char origin_buf[web_server_base::MAX_ORIGIN_LENGTH + 1]; + const size_t origin_len = request->get_header_to("Origin", origin_buf); + // Only an allow-any-origin build lets through an Origin too long for the buffer; it gets "*" instead + const bool has_origin = origin_len != 0 && origin_len != SIZE_MAX; + const char *origin = origin_buf; +#else + const AsyncWebHeader *origin_header = request->getHeader("Origin"); + const bool has_origin = origin_header != nullptr && origin_header->value().length() != 0; + const char *origin = has_origin ? origin_header->value().c_str() : nullptr; +#endif AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. - response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); + response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), has_origin ? origin : "*"); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -2430,20 +2391,12 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - // Private Network Access preflight carries a cross-origin Origin by design; its handler does the - // origin check itself, so let it run before the general enforcement below. if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { this->handle_pna_cors_request(request); return; } #endif - // Reject cross-origin browser requests unless the origin is explicitly allowed. - if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { - request->send(403); - return; - } - #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 13695e033ec..881641fe3ff 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -250,22 +250,6 @@ class WebServer final : public Component, public AsyncWebHandler { */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - /** Set the origins that browsers are allowed to make cross-origin requests from. - * - * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) - * are always allowed. Requests whose `Origin` matches the address the device is served on - * (same-origin) are always allowed. Any other browser origin must appear in this list, or the - * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match - * the requesting page's `Origin` header (e.g. "https://example.com"). - * - * This list is also used to authorize Private Network Access requests when that feature is enabled. - * - * @param origins nullptr-terminated table of allowed origins; must outlive the server. - */ - void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } -#endif - // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -643,16 +627,6 @@ class WebServer final : public Component, public AsyncWebHandler { #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - // Extra origins allowed to make cross-origin browser requests ("*" means any origin). - // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. - const char *const *allowed_origins_{nullptr}; -#endif - - /// Check whether the given request Origin is permitted. Same-origin (matching the Host the - /// request was sent to) and requests without an Origin header are always allowed; any other - /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. - bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index f290873c2f6..cb61b841f1b 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -28,6 +28,9 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +# Longest Origin the ESP32 server can read; keep in sync with MAX_ORIGIN_LENGTH in origin.h. +MAX_ORIGIN_LENGTH = 127 + @dataclass class WebServerBaseData: diff --git a/esphome/components/web_server_base/origin.cpp b/esphome/components/web_server_base/origin.cpp new file mode 100644 index 00000000000..e7391a5f407 --- /dev/null +++ b/esphome/components/web_server_base/origin.cpp @@ -0,0 +1,36 @@ +#include "origin.h" + +#include + +#include "esphome/core/string_ref.h" + +namespace esphome::web_server_base { + +bool is_origin_allowed(const char *origin, size_t origin_len, const char *host, size_t host_len, + const char *const *allowed) { + if (origin_len == 0) + return true; + + if (host_len != 0) { + // Same-origin: covers the device's IP, mDNS name or DNS name without knowing any at compile time. + // The scheme has no ':', so the first one starts "://". + const char *colon = static_cast(memchr(origin, ':', origin_len)); + if (colon != nullptr) { + const size_t authority = colon - origin + 3; + if (authority <= origin_len && colon[1] == '/' && colon[2] == '/' && + StringRef(origin + authority, origin_len - authority) == StringRef(host, host_len)) + return true; + } + } + + if (allowed == nullptr) + return false; + const StringRef origin_ref(origin, origin_len); + for (; *allowed != nullptr; allowed++) { + if (origin_ref == *allowed) + return true; + } + return false; +} + +} // namespace esphome::web_server_base diff --git a/esphome/components/web_server_base/origin.h b/esphome/components/web_server_base/origin.h new file mode 100644 index 00000000000..39ecfe00cb4 --- /dev/null +++ b/esphome/components/web_server_base/origin.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace esphome::web_server_base { + +/// Longest Origin or Host value the ESP32 server reads into its stack buffers; keep in sync with +/// MAX_ORIGIN_LENGTH in web_server_base/__init__.py. +static constexpr size_t MAX_ORIGIN_LENGTH = 127; + +/** Decide whether a browser request from the given Origin may reach a handler. + * + * A request without an Origin (origin_len == 0) is not a cross-origin browser request and is allowed. + * A same-origin request, whose Origin authority (the part after "scheme://") equals the Host header, + * is allowed. Any other origin must exactly equal an entry of the nullptr-terminated allowed table + * (nullptr when none is configured). A configured "*" never reaches this table: codegen turns it into + * USE_WEBSERVER_ALLOW_ANY_ORIGIN, which skips the check entirely. + * + * @param host Host header value; host_len == 0 when absent or unreadable. + */ +bool is_origin_allowed(const char *origin, size_t origin_len, const char *host, size_t host_len, + const char *const *allowed); + +} // namespace esphome::web_server_base diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 50144b6df73..ac170f8348d 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -5,13 +5,120 @@ namespace esphome::web_server_base { WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void WebServerBase::add_handler(AsyncWebHandler *handler) { -#ifdef USE_WEBSERVER_AUTH - if (credentials_.is_set()) { - handler = new internal::AuthMiddlewareHandler(handler, &credentials_); - } +#if defined(USE_WEBSERVER_ALLOWED_ORIGINS) || !defined(USE_WEBSERVER_ALLOW_ANY_ORIGIN) +// Not inlined, so its header copies leave the stack before the wrapped handler runs +static bool __attribute__((noinline)) is_request_origin_allowed(AsyncWebServerRequest *request, + const char *const *allowed) { +#ifdef USE_ESP32 + // Stack copies: the IDF server only hands out header values by copy + char origin[MAX_ORIGIN_LENGTH + 1]; + const size_t origin_len = request->get_header_to("Origin", origin); + if (origin_len == 0) + return true; + // Longer than codegen lets a configured entry be + if (origin_len == SIZE_MAX) + return false; + char host[MAX_ORIGIN_LENGTH + 1]; + size_t host_len = request->get_header_to("Host", host); + // A Host that does not fit means an Origin longer than the buffer, already handled above + if (host_len == SIZE_MAX) + host_len = 0; + return is_origin_allowed(origin, origin_len, host, host_len, allowed); +#else + const AsyncWebHeader *origin = request->getHeader("Origin"); + if (origin == nullptr) + return true; + const AsyncWebHeader *host = request->getHeader("Host"); + return is_origin_allowed(origin->value().c_str(), origin->value().length(), + host != nullptr ? host->value().c_str() : nullptr, + host != nullptr ? host->value().length() : 0, allowed); #endif - this->add_handler_without_auth(handler); +} +#endif + +namespace internal { + +bool GuardMiddlewareHandler::check_auth_(AsyncWebServerRequest *request) { +#ifdef USE_WEBSERVER_AUTH + const Credentials &credentials = global_web_server_base->credentials_; + if (!credentials.is_set()) + return true; + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials.username, credentials.password); +#else + bool success = request->authenticate(credentials.basic_auth_hash); +#endif + if (!success) { +#if USE_ESP32 + request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif + } + return success; +#else + return true; +#endif +} + +bool GuardMiddlewareHandler::check_origin_(AsyncWebServerRequest *request) { + // Codegen never sets both defines +#if defined(USE_WEBSERVER_ALLOWED_ORIGINS) + return is_request_origin_allowed(request, global_web_server_base->allowed_origins_); +#elif defined(USE_WEBSERVER_ALLOW_ANY_ORIGIN) + return true; +#else + return is_request_origin_allowed(request, nullptr); +#endif +} + +bool GuardMiddlewareHandler::check_chunk_(AsyncWebServerRequest *request, size_t index) { + // Every chunk is gated, but only the first of a request pays for the header copies and digest. + // A request always starts at index 0, so a new request reusing a freed request's address is + // checked again; an interleaved request has another address; a rejected one never sets passed_. + if (index != 0 && request == this->passed_) + return true; + if (!this->check_origin_(request) || !this->check_auth_(request)) { + this->passed_ = nullptr; + return false; + } + this->passed_ = request; + return true; +} + +void GuardMiddlewareHandler::handleRequest(AsyncWebServerRequest *request) { + this->passed_ = nullptr; + if (!this->check_origin_(request)) { + request->send(403); + return; + } + if (this->check_auth_(request)) + MiddlewareHandler::handleRequest(request); +} + +void GuardMiddlewareHandler::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, + uint8_t *data, size_t len, bool final) { + // A handler that buffers chunks never sees one from a rejected request + if (!this->check_chunk_(request, index)) + return; + MiddlewareHandler::handleUpload(request, filename, index, data, len, final); +} + +void GuardMiddlewareHandler::handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, + size_t total) { + if (!this->check_chunk_(request, index)) + return; + MiddlewareHandler::handleBody(request, data, len, index, total); +} + +} // namespace internal + +void WebServerBase::add_handler(AsyncWebHandler *handler) { + this->add_handler_without_auth(new internal::GuardMiddlewareHandler(handler)); } void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 38098971f4f..d695f113afd 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -4,6 +4,7 @@ #include #include "esphome/core/progmem.h" +#include "origin.h" #if USE_ESP32 #include "esphome/core/hal.h" @@ -60,53 +61,30 @@ struct Credentials { bool is_set() const { return basic_auth_hash != nullptr; } #endif }; +#endif -class AuthMiddlewareHandler : public MiddlewareHandler { +// Wraps every handler added through WebServerBase::add_handler(): rejects cross-origin browser +// requests the origin policy does not allow, then checks credentials when auth is configured. +// It holds only the next handler; the policy lives in global_web_server_base. +class GuardMiddlewareHandler : public MiddlewareHandler { public: - AuthMiddlewareHandler(AsyncWebHandler *next, Credentials *credentials) - : MiddlewareHandler(next), credentials_(credentials) {} + using MiddlewareHandler::MiddlewareHandler; - bool check_auth(AsyncWebServerRequest *request) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. -#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) - bool success = request->authenticate(credentials_->username, credentials_->password); -#else - bool success = request->authenticate(credentials_->basic_auth_hash); -#endif - if (!success) { -#if USE_ESP32 - request->requestAuthentication(); -#elif defined(USE_WEBSERVER_AUTH_DIGEST) - request->requestAuthentication(nullptr, true); -#else - request->requestAuthentication(nullptr, false); -#endif - } - return success; - } - - void handleRequest(AsyncWebServerRequest *request) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleRequest(request); - } + void handleRequest(AsyncWebServerRequest *request) override; void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data, - size_t len, bool final) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleUpload(request, filename, index, data, len, final); - } - void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleBody(request, data, len, index, total); - } + size_t len, bool final) override; + void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override; protected: - Credentials *credentials_; + // Upload and body chunks arrive before handleRequest(), which sends the 403 for a rejected origin. + bool check_chunk_(AsyncWebServerRequest *request, size_t index); + bool check_origin_(AsyncWebServerRequest *request); + // Always true when auth is not compiled in. + bool check_auth_(AsyncWebServerRequest *request); + + // Request whose first chunk passed the checks; see check_chunk_(). + const AsyncWebServerRequest *passed_{nullptr}; }; -#endif } // namespace internal @@ -153,15 +131,27 @@ class WebServerBase final { #else void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } #endif +#endif +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the extra origins browsers may make cross-origin requests from, for every handler added + * through add_handler(). Requests without an Origin header and same-origin requests are always + * allowed. Each entry must exactly match the browser's Origin header ("scheme://host[:port]"); + * "*" is not an entry here, codegen turns it into USE_WEBSERVER_ALLOW_ANY_ORIGIN instead. + * + * @param origins nullptr-terminated table of allowed origins; must outlive the server. + */ + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif void add_handler(AsyncWebHandler *handler); /** - * WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware. + * WARNING: Registers a handler that bypasses the auth and origin middleware. * * This should only be used for endpoints that are intentionally unauthenticated * (for example, captive portal or very limited-status endpoints). For normal * endpoints that should respect web server authentication, use add_handler(). + * The origin check is skipped too, so a captive portal-only build carries none of it; + * the portal's own pages are same-origin and the OS probes send no Origin. */ void add_handler_without_auth(AsyncWebHandler *handler); @@ -176,6 +166,11 @@ class WebServerBase final { #ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; #endif +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + const char *const *allowed_origins_{nullptr}; +#endif + + friend class internal::GuardMiddlewareHandler; }; } // namespace esphome::web_server_base diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index c58ca2a24ff..c12314ded26 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -50,6 +50,15 @@ optional request_get_header(httpd_req_t *req, const char *name) { return {str}; } +size_t request_get_header_to(httpd_req_t *req, const char *name, std::span buf) { + const size_t len = httpd_req_get_hdr_value_len(req, name); + if (len == 0) + return 0; + if (len >= buf.size() || httpd_req_get_hdr_value_str(req, name, buf.data(), buf.size()) != ESP_OK) + return SIZE_MAX; + return len; +} + optional query_key_value(const char *query_url, size_t query_len, const char *key) { if (query_url == nullptr || query_len == 0) { return {}; diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index 027a2f7b6cb..d0a41ec70bf 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 #include +#include #include #include "esphome/core/helpers.h" @@ -13,6 +14,9 @@ size_t url_decode(char *str); bool request_has_header(httpd_req_t *req, const char *name); optional request_get_header(httpd_req_t *req, const char *name); +/// Copy a header value into buf, nul-terminated, without heap allocation. +/// Returns its length, 0 when absent, or SIZE_MAX when it does not fit in buf. +size_t request_get_header_to(httpd_req_t *req, const char *name, std::span buf); optional query_key_value(const char *query_url, size_t query_len, const char *key); bool query_has_key(const char *query_url, size_t query_len, const char *key); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e82749ebbb5..87d5ddae6ea 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -45,6 +45,9 @@ namespace esphome::web_server_idf { #ifndef HTTPD_401 #define HTTPD_401 "401 Unauthorized" #endif +#ifndef HTTPD_403 +#define HTTPD_403 "403 Forbidden" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif @@ -334,6 +337,10 @@ optional AsyncWebServerRequest::get_header(const char *name) const return request_get_header(*this, name); } +size_t AsyncWebServerRequest::get_header_to(const char *name, std::span buf) const { + return request_get_header_to(*this, name, buf); +} + StringRef AsyncWebServerRequest::url_to(std::span buffer) const { const char *uri = this->req_->uri; const char *query_start = strchr(uri, '?'); @@ -369,6 +376,9 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 401: status = HTTPD_401; break; + case 403: + status = HTTPD_403; + break; case 404: status = HTTPD_404; break; diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index b017e6b36c4..6c801258f38 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -193,6 +193,9 @@ class AsyncWebServerRequest { operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; + /// Copy a header value into buf without heap allocation. + /// Returns its length, 0 when absent, or SIZE_MAX when it does not fit in buf. + size_t get_header_to(const char *name, std::span buf) const; // NOLINTNEXTLINE(readability-identifier-naming) bool hasHeader(const char *name) const; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f555d992a3a..cd3ea03ba1d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -521,10 +521,10 @@ bool WiFiComponent::needs_full_scan_results_() const { return false; } -bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const { +const WiFiAP *WiFiComponent::find_configured_network_(const char *ssid, const uint8_t *bssid) const { // Hidden networks in scan results have empty SSIDs - skip them if (ssid[0] == '\0') { - return false; + return nullptr; } for (const auto &sta : this->sta_) { // Skip hidden network configs (they don't appear in normal scans) @@ -534,16 +534,16 @@ bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t // For BSSID-only configs (empty SSID), match by BSSID if (sta.ssid_.empty()) { if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) { - return true; + return &sta; } continue; } // Match by SSID if (sta.ssid_ == ssid) { - return true; + return &sta; } } - return false; + return nullptr; } void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) { @@ -1744,10 +1744,11 @@ void WiFiComponent::check_scanning_finished() { if (!found_match) { ESP_LOGW(TAG, "No matching network found"); // No scan results matched our configured networks - transition directly to hidden mode - // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize) this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN); - // If no hidden networks to try, skip connection attempt (will be handled on next loop) + // No hidden network left: retry now instead of waiting out the scan timeout. With + // selected_sta_index_ at -1, retry_connect() penalizes no BSSID. if (this->selected_sta_index_ == -1) { + this->retry_connect(); return; } // Now start connection attempt in hidden mode diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 1201c02954b..d375ae05354 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -798,9 +798,12 @@ class WiFiComponent final : public Component { bool ssid_was_seen_in_scan_(const CompactString &ssid) const; /// Check if full scan results are needed (captive portal active, improv, listeners) bool needs_full_scan_results_() const; - /// Check if network matches any configured network (for scan result filtering) + /// The configured network a scan result belongs to, or nullptr (for scan result filtering) /// Matches by SSID when configured, or by BSSID for BSSID-only configs - bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const; + const WiFiAP *find_configured_network_(const char *ssid, const uint8_t *bssid) const; + bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const { + return this->find_configured_network_(ssid, bssid) != nullptr; + } /// Log a discarded scan result at VERBOSE level (skipped during roaming scans to avoid log overflow) void log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel); /// Find next SSID that wasn't in scan results (might be hidden) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 31f86248d90..47ba431880b 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -49,12 +49,20 @@ extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cf #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/util.h" namespace esphome::wifi { ESPHOME_LOG_TAG(TAG, "wifi_lt"); +#ifdef USE_RTL87XX +// rtw_connect_error_flag_t, in order, as wifi_get_last_error() returns it +PROGMEM_STRING_TABLE(JoinErrorStrings, "none", "network not found", "connect failed", + "handshake failed (wrong password?)", "4-way handshake timeout", "DHCP failed", "auth failed", + "assoc failed", "deauthenticated", "unknown"); +#endif + // Thread-safe event handling for LibreTiny WiFi // // LibreTiny's WiFi.onEvent() callback runs in the WiFi driver's thread context, @@ -274,7 +282,12 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ap.has_bssid() ? ap.get_bssid().data() : NULL); } if (status != WL_CONNECTED) { +#ifdef USE_RTL87XX + ESP_LOGW(TAG, "WiFi.begin failed: %d (%s)", status, + LOG_STR_ARG(JoinErrorStrings::get_log_str(wifi_get_last_error(), JoinErrorStrings::LAST_INDEX))); +#else ESP_LOGW(TAG, "WiFi.begin failed: %d", status); +#endif // Without this reset the state machine stays at CONNECTING and each retry // stalls for the full connect timeout (46 s). this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); @@ -491,6 +504,12 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { auto &it = event->data.scan_done; ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); +#ifdef USE_RTL87XX + // The driver also reports the scan inside each join; taking that one would clear the results the + // retry phases are working from, so only a scan this component started counts + if (this->state_ != WIFI_COMPONENT_STATE_STA_SCANNING && !this->is_roaming_scan_active()) + break; +#endif this->wifi_scan_done_callback_(); break; } @@ -760,10 +779,25 @@ void WiFiComponent::wifi_scan_done_callback_() { for (int i = 0; i < num; i++) { const char *ssid_cstr = scan->ap[i].ssid; auto &ap = scan->ap[i]; - if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) { + const WiFiAP *config = this->find_configured_network_(ssid_cstr, ap.bssid.addr); + if (needs_full || config != nullptr) { +#ifdef USE_RTL87XX + // LibreTiny maps the low byte of the driver's security type as an encryption type, so WEP comes out OPEN + // and open, WPA/WPA2 mixed and PMF networks all come out INVALID. For INVALID, follow the configured network. + bool with_auth = true; + if (ap.auth == WIFI_AUTH_INVALID && config != nullptr) { +#ifdef USE_WIFI_WPA2_EAP + with_auth = !config->password_.empty() || config->get_eap().has_value(); +#else + with_auth = !config->password_.empty(); +#endif + } +#else + const bool with_auth = ap.auth != WIFI_AUTH_OPEN; +#endif this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], ap.bssid.addr[4], ap.bssid.addr[5]}, - ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, + ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, with_auth, ssid_cstr[0] == '\0'); } else { this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); diff --git a/esphome/core/automation.h b/esphome/core/automation.h index d00acd0a6d2..6ee72d00cc4 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" +#include #include #include #include @@ -82,9 +83,9 @@ template class TemplatableFn { /// Byte payload that is either a stateless lambda or a static table, which may be in PROGMEM. /// 8 bytes on 32-bit; codegen stores constant payloads as shared flash tables. -template class TemplatableBytes { +template class TemplatableBuffer { public: - void set_template(std::vector (*func)(Ts...)) { + void set_template(B (*func)(Ts...)) { this->code_.func = func; this->len_ = -1; } @@ -97,53 +98,68 @@ template class TemplatableBytes { const uint8_t *data() const { return this->code_.data; } /// Only valid when is_static(). size_t size() const { return static_cast(this->len_); } - std::vector value(const Ts &...x) const { + B value(const Ts &...x) const { if (this->len_ < 0) return this->code_.func(x...); - return to_vector(this->code_.data, this->size()); + return to_buffer(this->code_.data, this->size()); } - /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: a lambda's vector, a static + /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: the lambda's buffer, a static /// table directly, or on ESP8266 a copy of the PROGMEM table (on the stack up to N bytes). template void visit(F &&fn, const Ts &...x) const { if (this->len_ < 0) { - const std::vector bytes = this->code_.func(x...); + const B bytes = this->code_.func(x...); fn(bytes.data(), bytes.size()); return; } + const size_t len = this->static_len_(); #ifdef USE_ESP8266 - SmallBufferWithHeapFallback buf(this->size()); - if (this->len_ != 0) - progmem_memcpy(buf.get(), this->code_.data, this->size()); - fn(buf.get(), this->size()); + SmallBufferWithHeapFallback buf(len); + if (len != 0) + progmem_memcpy(buf.get(), this->code_.data, len); + fn(buf.get(), len); #else - fn(this->code_.data, this->size()); + fn(this->code_.data, len); #endif } protected: - static std::vector to_vector(const uint8_t *data, size_t len) { - std::vector out(len); + // A fixed-capacity B keeps only what fits, so visit() passes what value() returns + size_t static_len_() const { + if constexpr (requires { B::capacity(); }) { + return std::min(this->size(), B::capacity()); + } else { + return this->size(); + } + } + + static B to_buffer(const uint8_t *data, size_t len) { + B out; + out.resize(len); // An empty payload is (nullptr, 0), and memcpy from nullptr is undefined even for zero bytes. - if (len != 0) - progmem_memcpy(out.data(), data, len); // byte loads from flash fault on ESP8266 + if (out.size() != 0) + progmem_memcpy(out.data(), data, out.size()); // byte loads from flash fault on ESP8266 return out; } union { - std::vector (*func)(Ts...); + B (*func)(Ts...); const uint8_t *data; } code_{}; int32_t len_{-1}; // -1: lambda, otherwise the length of the static table }; -#define TEMPLATABLE_BYTES(name) \ +template using TemplatableBytes = TemplatableBuffer, Ts...>; + +#define TEMPLATABLE_BUFFER(name, ...) \ protected: \ - TemplatableBytes name##_{}; \ + TemplatableBuffer<__VA_ARGS__, Ts...> name##_{}; \ \ public: \ - void set_##name##_template(std::vector (*func)(Ts...)) { this->name##_.set_template(func); } \ + void set_##name##_template(__VA_ARGS__ (*func)(Ts...)) { this->name##_.set_template(func); } \ void set_##name##_static(const uint8_t *data, uint16_t len) { this->name##_.set_static(data, len); } +#define TEMPLATABLE_BYTES(name) TEMPLATABLE_BUFFER(name, std::vector) + // Forward declaration for TemplatableValue (string specialization needs it) template class TemplatableValue; diff --git a/esphome/core/crash_report.h b/esphome/core/crash_report.h new file mode 100644 index 00000000000..dac7faa7ca0 --- /dev/null +++ b/esphome/core/crash_report.h @@ -0,0 +1,70 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CRASH_REPORT + +#include +#include + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome { + +/// Per core +static constexpr uint8_t CRASH_REPORT_MAX_BACKTRACE = 16; +#ifdef USE_ESP32 +static constexpr uint8_t CRASH_REPORT_MAX_CORES = SOC_CPU_CORES_NUM; +#else +static constexpr uint8_t CRASH_REPORT_MAX_CORES = 1; +#endif + +/// Sent as is over the native API +enum CrashReportFlag : uint8_t { + CRASH_REPORT_FLAG_NONE = 0, + CRASH_REPORT_FLAG_PRESENT = 1 << 0, // Stored and not yet read + CRASH_REPORT_FLAG_BUILD_MATCHES = 1 << 1, // Recorded by the running firmware; assumed on ESP8266, RP2 and LibreTiny + CRASH_REPORT_FLAG_CAUSE = 1 << 2, // cause is valid + CRASH_REPORT_FLAG_FAULT_ADDRESS = 1 << 3, // fault_address is valid +}; + +/// Sent as is over the native API +enum class CrashReason : uint8_t { + CRASH_REASON_UNKNOWN = 0, + CRASH_REASON_EXCEPTION = 1, + CRASH_REASON_ABORT = 2, + CRASH_REASON_TASK_WATCHDOG = 3, + CRASH_REASON_INTERRUPT_WATCHDOG = 4, + CRASH_REASON_SOFT_WATCHDOG = 5, + CRASH_REASON_HARDWARE_WATCHDOG = 6, // Only the reason is recorded +}; + +struct CrashCoreBacktrace { + uint8_t core{0}; + uint8_t count{0}; + std::array addresses{}; +}; + +struct CrashReport { + uint8_t flags{CRASH_REPORT_FLAG_NONE}; + CrashReason reason{CrashReason::CRASH_REASON_UNKNOWN}; + uint8_t crashed_core{0}; + uint8_t core_count{0}; + uint32_t pc{0}; + uint32_t cause{0}; + uint32_t fault_address{0}; + uint32_t build_time{0}; + std::array cores{}; +}; + +/// The previous boot's crash; false when there is none or it was read. Implemented per platform. +bool crash_report_get(CrashReport &out); + +/// Mark the report read; it also no longer survives a reboot. +void crash_report_clear(); + +} // namespace esphome + +#endif // USE_CRASH_REPORT diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 449836c55cd..a9d3165a2ee 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -62,15 +62,17 @@ #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 #define USE_BQ25186_PG_GPO -#define BQ25186_BINARY_SENSOR_COUNT 1 -#define BQ25186_TEXT_SENSOR_COUNT 1 +#define USE_BQ25186_STATUS_PUBLISHER +#define USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION #define USE_BUTTON #define USE_CAMERA +#define USE_CANBUS_FD #define USE_CLIMATE #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER #define USE_CRASH_HANDLER +#define USE_CRASH_REPORT #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME @@ -272,7 +274,6 @@ #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define SX1509_LED_CHANNEL_COUNT 2 -#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 @@ -339,11 +340,14 @@ #define USE_STATUS_SENSOR #define USE_SWITCH #define USE_SWITCH_STORAGE +#define USE_SY6970_DATA_PUBLISHER +#define USE_TCA8418_KEY_PUBLISHER #define USE_TEXT #define USE_TEXT_SENSOR #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TM1638_KEYS_PUBLISHER #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TMP102_NUMBER @@ -397,8 +401,10 @@ #define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#define USE_STORE_YAML #define API_MAX_SEND_QUEUE 8 #define API_WIZARD_DATA_SIZE 1 +#define STORE_YAML_DATA_SIZE 1 #define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment @@ -410,6 +416,7 @@ #define USE_SHA256 #if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT +#define MQTT_MAX_PAYLOAD_SIZE 32768 #define USE_MQTT_ABBREVIATIONS #define USE_MQTT_SESSION_PERSISTENCE #define USE_MQTT_COVER_JSON @@ -587,6 +594,7 @@ #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING #define USE_WEBSERVER_ALLOWED_ORIGINS +#define USE_WEBSERVER_ALLOW_ANY_ORIGIN #define USE_WEBSERVER_CSS_INCLUDE #define USE_WEBSERVER_JS_INCLUDE #define USE_WEBSERVER_PRIVATE_NETWORK_ACCESS @@ -690,6 +698,7 @@ #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD +#define USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD #define USE_OPENTHREAD_SRP @@ -785,6 +794,7 @@ #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC +#define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE #define USE_RP2_BLE_TRACKER diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3adf38ce011..dc5f187213c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -58,6 +58,18 @@ #define ESPHOME_ALWAYS_INLINE __attribute__((always_inline)) #define PACKED __attribute__((packed)) +/// Declares the `_publisher_` function pointer and `set__publisher()` that a cg.HubFunction named +/// `` fills; the pointer stays nullptr when no child added an item, so check it before calling. +/// Use it in a public section: it ends on `public:`. +#define HUB_PUBLISHER(name, ...) HUB_PUBLISHER_RETURNING(void, name, __VA_ARGS__) +/// HUB_PUBLISHER for a cg.HubFunction that returns `ret` (bool: whether any item was true). +#define HUB_PUBLISHER_RETURNING(ret, name, ...) \ + protected: \ + ret (*name##_publisher_)(__VA_ARGS__){nullptr}; \ +\ + public: \ + void set_##name##_publisher(ret (*publisher)(__VA_ARGS__)) { this->name##_publisher_ = publisher; } + namespace esphome { // Forward declaration to avoid circular dependency with string_ref.h @@ -448,6 +460,17 @@ template class StaticRingBuffer { return true; } + /// Insert `value` so it is popped next; returns false when full. + bool push_front(const T &value) { + if (this->count_ >= N) { + return false; + } + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + void pop() { if (this->count_ > 0) { this->head_ = (this->head_ + 1) % N; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 527b95efed2..0151d9084a6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -793,7 +793,7 @@ def static_function( name: str, return_type: SafeExpType, parameters: TemplateArgsType, - body: list[str], + body: list[str | Statement], ) -> RawExpression: """Emit ``static (parameters) { body }`` at global scope and return an expression naming it, for use as a template argument or a function pointer. @@ -807,7 +807,7 @@ def static_function( ) add_global(RawStatement(f"{signature};")) add_global_after_includes( - RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}") + RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}") ) return RawExpression(name) @@ -1343,6 +1343,10 @@ class MockObjClass(MockObj): return True return any(str(parent) == str(other) for parent in self._parents) + def const_ref(self) -> MockObj: + """``const T &``, for a parameter that takes this type by reference.""" + return self.operator("const").operator("ref") + def template(self, *args: SafeExpType) -> "MockObjClass": if len(args) != 1 or not isinstance(args[0], TemplateArguments): args = TemplateArguments(*args) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 70090ddf27f..842a9827fa7 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -14,17 +14,22 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import ( + AssignmentExpression, + BinOpExpression, MockObj, RawStatement, + Statement, add, add_define, add_global, get_variable, new_Pvariable, + statement, + static_function, ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape -from esphome.types import ConfigFragmentType, ConfigType, Expression +from esphome.types import ConfigFragmentType, ConfigType, Expression, TemplateArgsType from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) @@ -216,6 +221,117 @@ def slot_counter(define: str) -> Callable[..., None]: return request_slot +_HUB_FUNCTION_DOMAIN = "hub_function" + + +@dataclass +class _HubFunctionState: + """One run's items per hub (keyed by ``str(hub)``) for one HubFunction.""" + + hubs: dict[str, tuple[MockObj, list[Expression | Statement]]] = field( + default_factory=dict + ) + emitted: bool = False + + +@dataclass +class _HubFunctionData: + """One run's state for every HubFunction, keyed by the instance, plus the names emitted.""" + + states: dict["HubFunction", _HubFunctionState] = field(default_factory=dict) + names: set[str] = field(default_factory=set) + + +def _hub_function_data() -> _HubFunctionData: + return CORE.data.setdefault(_HUB_FUNCTION_DOMAIN, _HubFunctionData()) + + +def _arg(name: str) -> str: + # Prefixed, so a parameter cannot hide an entity id the items name + return f"esphome__arg_{name}" + + +class HubFunction: + """One generated function per hub, ``esphome_____fn``, built from the items its children + ``add`` in ``to_code`` and passed to the ``set__publisher()`` that ``HUB_PUBLISHER`` declares. + A hub without items keeps ``nullptr``. ``void`` items are statements or expressions; ``bool`` items + are expressions that all run (no short-circuit), and the function returns whether any was true. + """ + + def __init__( + self, + name: str, + return_type: MockObj, + parameters: TemplateArgsType, + *, + define: str | None = None, + ) -> None: + if str(return_type) not in ("void", "bool"): + raise ValueError(f"HubFunction '{name}' returns void or bool") + self._name = name + self._return_type = return_type + self._returns_bool = str(return_type) == "bool" + self._parameters = [(type_, _arg(arg)) for type_, arg in parameters] + self._define = define + + @coroutine_with_priority(CoroPriority.FINAL) + async def emit_job() -> None: + self._emit() + + self._emit_job = emit_job + + def param(self, name: str) -> MockObj: + """The parameter ``name``, for use in the items.""" + arg = _arg(name) + if arg not in (param for _, param in self._parameters): + raise ValueError(f"HubFunction '{self._name}' has no parameter '{name}'") + return MockObj(arg) + + def add(self, hub: MockObj, item: Expression | Statement) -> None: + """Append ``item`` to the function of ``hub``, a plain variable such as ``cg.get_variable``'s.""" + if self._returns_bool and isinstance(item, Statement): + raise ValueError(f"HubFunction '{self._name}' takes bool expressions") + state = self._state() + if state.emitted: + raise ValueError( + f"HubFunction '{self._name}': item for {hub} added after the " + f"functions were emitted; add items from to_code" + ) + if not state.hubs: + CORE.add_job(self._emit_job) + state.hubs.setdefault(str(hub), (hub, []))[1].append(item) + + def _state(self) -> _HubFunctionState: + # Keyed by the instance, which lives for the process; CORE.data clears between runs + return _hub_function_data().states.setdefault(self, _HubFunctionState()) + + def _body(self, items: list[Expression | Statement]) -> list[Statement]: + if not self._returns_bool: + return [statement(item) for item in items] + any_ = MockObj("esphome__found") + return [ + statement(AssignmentExpression(self._return_type, "", any_, False)), + *(statement(BinOpExpression(any_, "|=", item)) for item in items), + RawStatement(f"return {any_};"), + ] + + def _emit(self) -> None: + state = self._state() + state.emitted = True + if self._define is not None: + add_define(self._define) + names = _hub_function_data().names + for hub, items in state.hubs.values(): + name = f"esphome__{hub}__{self._name}_fn" + if name in names: + raise ValueError(f"HubFunction: two functions named {name}") + names.add(name) + fn = static_function( + name, self._return_type, self._parameters, self._body(items) + ) + add(getattr(hub, f"set_{self._name}_publisher")(fn)) + + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 809ea4aea26..e94b63cf005 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -28,7 +28,7 @@ dependencies: espressif/esp_modem: version: 2.1.0 espressif/mdns: - version: 1.14.0 + version: 1.15.0 espressif/esp_wifi_remote: version: 1.6.5 rules: diff --git a/requirements.txt b/requirements.txt index 19c52a9aeb6..4bc123e9699 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,9 +12,9 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.25.1 +aioesphomeapi==46.28.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi -zeroconf==0.152.0 +zeroconf==0.153.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.14.2 # native esp-idf toolchain global cache dir +platformdirs==4.14.3 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg diff --git a/requirements_test.txt b/requirements_test.txt index 9a8e9e9ac34..6ea2c15f48b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ pytest==9.2.0 pytest-cov==7.1.0 pytest-mock==3.17.0 pytest-timeout==2.4.0 -pytest-asyncio==1.4.1 +pytest-asyncio==1.4.1.post1 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 115a6ef2fd9..8fc1b87bd4c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -102,6 +102,7 @@ ISOLATED_COMPONENTS = { "modem": "Cellular-only node with api - grouping with wifi-bearing components hides that case", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", + "store_yaml": "Embeds the test's own YAML; grouping would change the blob and its redaction", "tinyusb": "Conflicts with usb_host component - cannot be used together", "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 881733215b6..46dba7e305d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import copy import inspect import json import os @@ -561,34 +562,77 @@ def get_arr_path_schema(path): return s -def merge(source, destination): - """ - run me with nosetests --with-doctest file.py - - >>> a = { 'first' : { 'all_rows' : { 'pass' : 'dog', 'number' : '1' } } } - >>> b = { 'first' : { 'all_rows' : { 'fail' : 'cat', 'number' : '5' } } } - >>> merge(b, a) == { 'first' : { 'all_rows' : { 'pass' : 'dog', 'fail' : 'cat', 'number' : '5' } } } - True - """ - for key, value in source.items(): - if isinstance(value, dict): - # get node or create one - node = destination.setdefault(key, {}) - merge(value, node) - elif key == "constraints" and isinstance(value, list): - destination[key] = value + destination.get(key, []) - else: - destination[key] = value - - return destination - - def inline_schema(node: dict, base: dict) -> None: - """Copy *base* into *node*, keeping the key constraints of both.""" + """Copy *base* into *node*, keeping the key constraints and config vars of both.""" local = node.get("constraints", []) + local_vars = node.get(S_CONFIG_VARS) node |= base if local: node["constraints"] = base.get("constraints", []) + local + if local_vars: + node[S_CONFIG_VARS] = copy.deepcopy( + overlay_config_vars(local_vars, base.get(S_CONFIG_VARS, {})) + ) + + +def overlay_field(local: dict, base: dict) -> dict: + """Overlay the field descriptor *local* on *base*, the one it inherits. + + Only the schema structure recurses (a nested ``schema`` and its ``config_vars``, + whose key constraints are kept from both); any other value *local* sets, such + as a narrowed ``values`` map or its own ``id_type``, replaces the base's. + """ + merged = base | local + local_schema, base_schema = local.get(S_SCHEMA), base.get(S_SCHEMA) + if isinstance(local_schema, dict) and isinstance(base_schema, dict): + schema = base_schema | local_schema + if S_CONFIG_VARS in local_schema and S_CONFIG_VARS in base_schema: + schema[S_CONFIG_VARS] = overlay_config_vars( + local_schema[S_CONFIG_VARS], base_schema[S_CONFIG_VARS] + ) + if "constraints" in local_schema and "constraints" in base_schema: + schema["constraints"] = ( + base_schema["constraints"] + local_schema["constraints"] + ) + merged[S_SCHEMA] = schema + return merged + + +def overlay_config_vars(local: dict, base: dict) -> dict: + """Overlay *local* field descriptors on *base*'s, one field at a time.""" + return base | { + name: overlay_field(field, base[name]) + if isinstance(base.get(name), dict) + else field + for name, field in local.items() + } + + +def splice_extends(node: dict, extends: list, name: str) -> None: + """Set *node*'s extends to *extends*, with *name* replaced by the extends *node* inherited from it. + + >>> node = {"extends": ["d", "a"]} + >>> splice_extends(node, ["a", "b", "c"], "b") + >>> node + {'extends': ['a', 'd', 'c']} + """ + inherited = node.pop(S_EXTENDS, []) + idx = extends.index(name) + added = [e for e in inherited if e not in extends] + if spliced := extends[:idx] + added + extends[idx + 1 :]: + node[S_EXTENDS] = spliced + + +SIMPLE_TYPES = ("enum", "registry", "integer", "string") + + +def drop_extend(node: dict, name: str) -> None: + """Remove *name* from *node*'s extends, dropping the key when it empties.""" + # Rebuilt, not mutated: inlined nodes can share one extends list + if extends := [e for e in node[S_EXTENDS] if e != name]: + node[S_EXTENDS] = extends + else: + node.pop(S_EXTENDS) def is_platform_schema(schema_name): @@ -631,19 +675,30 @@ def shrink(): # key_s.pop(S_EXTENDS) pass_again = True if S_SCHEMA in arr_s: - if S_EXTENDS in arr_s[S_SCHEMA]: - arr_s[S_SCHEMA].pop(S_EXTENDS) - else: + extends = arr_s[S_SCHEMA].pop(S_EXTENDS, None) + if extends is None: print("expected extends here!" + x) - arr_s = merge(key_s, arr_s) + merged = overlay_field(arr_s, key_s) + # The inlined schema decides the type (an enum or typed collapse) + merged[S_TYPE] = key_s.get(S_TYPE, merged.get(S_TYPE)) + arr_s.clear() + arr_s.update(copy.deepcopy(merged)) + if extends: + splice_extends(arr_s[S_SCHEMA], extends, x) # Only key constraints survive the collapse - if arr_s[S_TYPE] in ["enum", "typed"] and ( - constraints := arr_s.pop(S_SCHEMA).get("constraints") - ): - arr_s[S_SCHEMA] = {"constraints": constraints} + if arr_s[S_TYPE] in ["enum", "typed"]: + collapsed = arr_s.pop(S_SCHEMA) + if siblings := collapsed.get(S_EXTENDS): + print( + f"dropped {siblings} beside sibling extends at " + + ".".join(paths[0]) + ) + if constraints := collapsed.get("constraints"): + arr_s[S_SCHEMA] = {"constraints": constraints} else: - arr_s.pop(S_EXTENDS) + extends = arr_s.pop(S_EXTENDS) inline_schema(arr_s, key_s[S_SCHEMA]) + splice_extends(arr_s, extends, x) # simple types should be spread on each component, # for enums so far these are logger.is_log_level, cover.validate_cover_state and pulse_counter.sensor.COUNT_MODE_SCHEMA @@ -651,7 +706,7 @@ def shrink(): # then are all simple types, integer and strings for x, paths in referenced_schemas.items(): key_s = get_str_path_schema(x) - if key_s and key_s.get(S_TYPE) in ["enum", "registry", "integer", "string"]: + if key_s and key_s.get(S_TYPE) in SIMPLE_TYPES: if key_s[S_TYPE] == "registry": print("Spreading registry: " + x) for target in paths: @@ -659,7 +714,23 @@ def shrink(): if S_SCHEMA not in target_s: print("skipping simple spread for " + ".".join(target)) continue - assert target_s[S_SCHEMA][S_EXTENDS] == [x] + extends = target_s[S_SCHEMA][S_EXTENDS] + assert x in extends + if len(extends) > 1: + # Spliced beside sibling extends (cv.Any(A, B)): keep the siblings + drop_extend(target_s[S_SCHEMA], x) + if any( + get_str_path_schema(e).get(S_TYPE) in SIMPLE_TYPES + for e in target_s[S_SCHEMA].get(S_EXTENDS, []) + ): + continue + if key_s[S_TYPE] == "enum" and "values" not in target_s: + target_s["values"] = key_s["values"] + else: + print( + f"dropped {x} beside sibling extends at " + ".".join(target) + ) + continue target_s.pop(S_SCHEMA) target_s |= key_s if key_s[S_TYPE] in ["integer", "string"]: @@ -671,9 +742,15 @@ def shrink(): target_s = get_arr_path_schema(target) if S_SCHEMA not in target_s: # an empty schema like speaker.SPEAKER_SCHEMA - target_s[S_EXTENDS].remove(x) + drop_extend(target_s, x) + continue + target_schema = target_s[S_SCHEMA] + extends = target_schema[S_EXTENDS] + assert x in extends + if target_schema.get(S_CONFIG_VARS) or len(extends) > 1: + # A bare validator beside real keys (cv.All(SCHEMA, check)) adds nothing + drop_extend(target_schema, x) continue - assert x in target_s[S_SCHEMA][S_EXTENDS] target_s.pop(S_SCHEMA) target_s.pop(S_TYPE) # undefined target_s["data_type"] = x.split(".")[1] @@ -1009,13 +1086,9 @@ def convert(schema, config_var, path): config_var.update(convert_config(data[0], path + "/maybe")) # esphome/on_boot elif schema_type == "automation": - extra_schema = None config_var[S_TYPE] = "trigger" - if ejs.extended_schemas[repr(data)][0] == automation.AUTOMATION_SCHEMA: - extra_schema = ejs.extended_schemas[repr(data)][1] - if ( - extra_schema is not None and len(extra_schema) > 1 - ): # usually only trigger_id here + entry = ejs.extended_schemas[repr(data)] + if entry[0] == automation.AUTOMATION_SCHEMA and (extra_schema := entry[1]): config = convert_config(extra_schema, path + "/extra") if "schema" in config: automation_schema = config["schema"] @@ -1109,7 +1182,8 @@ def convert(schema, config_var, path): config_var["typed_key"] = typed_schema[1].get("key", CONF_TYPE) for schema_key, schema_type in typed_schema[0][0].items(): config = convert_config(schema_type, path + "/type_" + schema_key) - types[schema_key] = config["schema"] + # A type with no keys of its own converts to nothing. + types[schema_key] = config.get("schema", {S_CONFIG_VARS: {}}) elif DUMP_UNKNOWN and S_TYPE not in config_var: config_var["unknown"] = repr_schema diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index c8b56044de5..3ebc52ce848 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -161,15 +161,16 @@ static std::vector make_ir_timings_100() { return v; } -static const std::vector &get_ir_timings_100() { +static const std::span *get_ir_timings_100() { static const std::vector timings = make_ir_timings_100(); - return timings; + static const std::span span(timings); + return &span; } static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { InfraredRFReceiveEvent msg; msg.key = 0xDEADBEEF; - msg.timings = &get_ir_timings_100(); + msg.timings = get_ir_timings_100(); APIBuffer buffer; (void) buffer.resize(msg.calculate_size()); @@ -187,7 +188,7 @@ BENCHMARK(Encode_InfraredRFReceiveEvent); static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { InfraredRFReceiveEvent msg; msg.key = 0xDEADBEEF; - msg.timings = &get_ir_timings_100(); + msg.timings = get_ir_timings_100(); for (auto _ : state) { uint32_t result = 0; diff --git a/tests/component_tests/bq25186/config/hub_function.yaml b/tests/component_tests/bq25186/config/hub_function.yaml new file mode 100644 index 00000000000..65ae5c7f67b --- /dev/null +++ b/tests/component_tests/bq25186/config/hub_function.yaml @@ -0,0 +1,108 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + - id: i2c_bus + scl: GPIO16 + sda: GPIO17 + +bq25186: + - id: charger_a + i2c_id: i2c_bus + - id: charger_b + i2c_id: i2c_bus + address: 0x6B + - id: charger_c + i2c_id: i2c_bus + address: 0x6C + +# charger_a has every status entity, charger_b one, charger_c none. +# Items publish in config order, so these text sensors come first +text_sensor: + - platform: bq25186 + bq25186_id: charger_a + charge_status: + id: tx_charge_status + name: A charge_status + ts_status: + id: tx_ts_status + name: A ts_status + - platform: bq25186 + bq25186_id: charger_b + ts_status: + id: b_ts_status + name: B ts_status + +# Two blocks on one hub publish in block order +binary_sensor: + - platform: bq25186 + bq25186_id: charger_a + ts: + id: bs_ts + name: A ts + charging: + id: bs_charging + name: A charging + charge_done: + id: bs_charge_done + name: A charge_done + ilim: + id: bs_ilim + name: A ilim + vdppm: + id: bs_vdppm + name: A vdppm + vindpm: + id: bs_vindpm + name: A vindpm + thermreg: + id: bs_thermreg + name: A thermreg + power_good: + id: bs_power_good + name: A power_good + vin_ovp: + id: bs_vin_ovp + name: A vin_ovp + battery_uvlo: + id: bs_battery_uvlo + name: A battery_uvlo + - platform: bq25186 + bq25186_id: charger_a + safety_timer_flag: + id: bs_safety_timer_flag + name: A safety_timer_flag + wake1_flag: + id: bs_wake1_flag + name: A wake1_flag + wake2_flag: + id: bs_wake2_flag + name: A wake2_flag + ts_flag: + id: bs_ts_flag + name: A ts_flag + ilim_flag: + id: bs_ilim_flag + name: A ilim_flag + vdppm_flag: + id: bs_vdppm_flag + name: A vdppm_flag + vindpm_flag: + id: bs_vindpm_flag + name: A vindpm_flag + thermreg_flag: + id: bs_thermreg_flag + name: A thermreg_flag + vin_ovp_flag: + id: bs_vin_ovp_flag + name: A vin_ovp_flag + battery_uvlo_flag: + id: bs_battery_uvlo_flag + name: A battery_uvlo_flag + battery_ocp_flag: + id: bs_battery_ocp_flag + name: A battery_ocp_flag diff --git a/tests/component_tests/bq25186/status_datasheet.py b/tests/component_tests/bq25186/status_datasheet.py new file mode 100644 index 00000000000..a6d7b69959a --- /dev/null +++ b/tests/component_tests/bq25186/status_datasheet.py @@ -0,0 +1,74 @@ +"""The BQ25186 status fields from the datasheet, for checking the entity decode.""" + +from pathlib import Path +import re + +# Offsets into the status registers the hub reads from STAT0 +STAT0 = 0 +STAT1 = 1 +FLAG0 = 2 + +# Binary sensor key: (register, field bits, field values that read as on), in publish order +BINARY_SENSORS: dict[str, tuple[int, int, frozenset[int]]] = { + "ts": (STAT0, 0x80, frozenset({1})), + "charging": (STAT0, 0x60, frozenset({1, 2})), + "charge_done": (STAT0, 0x60, frozenset({3})), + "ilim": (STAT0, 0x10, frozenset({1})), + "vdppm": (STAT0, 0x08, frozenset({1})), + "vindpm": (STAT0, 0x04, frozenset({1})), + "thermreg": (STAT0, 0x02, frozenset({1})), + "power_good": (STAT0, 0x01, frozenset({1})), + "vin_ovp": (STAT1, 0x80, frozenset({1})), + "battery_uvlo": (STAT1, 0x40, frozenset({1})), + "safety_timer_flag": (STAT1, 0x04, frozenset({1})), + "wake1_flag": (STAT1, 0x02, frozenset({1})), + "wake2_flag": (STAT1, 0x01, frozenset({1})), + "ts_flag": (FLAG0, 0x80, frozenset({1})), + "ilim_flag": (FLAG0, 0x40, frozenset({1})), + "vdppm_flag": (FLAG0, 0x20, frozenset({1})), + "vindpm_flag": (FLAG0, 0x10, frozenset({1})), + "thermreg_flag": (FLAG0, 0x08, frozenset({1})), + "vin_ovp_flag": (FLAG0, 0x04, frozenset({1})), + "battery_uvlo_flag": (FLAG0, 0x02, frozenset({1})), + "battery_ocp_flag": (FLAG0, 0x01, frozenset({1})), +} + +# Text sensor key: (register, field bits, the state for each field value), in publish order +TEXT_SENSORS: dict[str, tuple[int, int, tuple[str, ...]]] = { + "charge_status": ( + STAT0, + 0x60, + ( + "Not Charging", + "Constant Current", + "Constant Voltage", + "Charge Done/Disabled", + ), + ), + "ts_status": ( + STAT1, + 0x18, + ( + "Normal", + "Hot/Cold (Charging Suspended)", + "Cool (Current Reduced)", + "Warm (Voltage Reduced)", + ), + ), +} + + +def field_value(value: int, bits: int) -> int: + """The field `bits` of the register `value`, shifted down.""" + return (value & bits) >> ((bits & -bits).bit_length() - 1) + + +def status_strings() -> list[str]: + """The strings of the component's PROGMEM_STRING_TABLE, in table order.""" + component = Path(__file__).parents[3] / "esphome" / "components" / "bq25186" + for path in sorted(component.glob("*.[ch]*")): + if match := re.search( + r"PROGMEM_STRING_TABLE\(\w+,(.*?)\);", path.read_text(), re.DOTALL + ): + return re.findall(r'"([^"]*)"', match[1]) + raise AssertionError("no PROGMEM_STRING_TABLE in the bq25186 component") diff --git a/tests/component_tests/bq25186/test_hub_function.py b/tests/component_tests/bq25186/test_hub_function.py new file mode 100644 index 00000000000..7525cae0b51 --- /dev/null +++ b/tests/component_tests/bq25186/test_hub_function.py @@ -0,0 +1,93 @@ +"""Tests for the generated bq25186 status publishers.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components.bq25186.binary_sensor import _state_expression + +from .status_datasheet import BINARY_SENSORS, TEXT_SENSORS, field_value, status_strings + +PARAMS = "const bq25186::BQ25186StatusRegisters & esphome__arg_status" +BINARY = re.compile(r"::bs_(\w+)->publish_state\((.*)\);") +LAST = re.compile(r"static uint8_t esphome__tx_(\w+)__last = 0xFF;") +TEXT = re.compile( + r"bq25186::publish_status_text\(::tx_(\w+), esphome__tx_\1__last, (.*), (\d+)\);" +) +# The hand copy of the generated function in the C++ behaviour tests +CPP_TEST = Path(__file__).parents[2] / "components/bq25186/test_bq25186_status.cpp" +CPP_BINARY = re.compile(r"entities->binary\[(\w+)\]\.publish_state\((.*)\);") +CPP_TEXT = re.compile( + r"publish_status_text\(&entities->text\[(\w+)\], entities->\w+, (.*), (\d+)\);" +) + + +def _body(main_cpp: str, hub: str) -> list[str]: + head = f"static void esphome__{hub}__status_fn({PARAMS}) {{\n" + body = main_cpp.split(head, 1)[1].split("\n}", 1)[0] + return [line.strip() for line in body.splitlines()] + + +def _check(expr: str, reg: int, bits: int, expected: list[int]) -> None: + """Evaluate a generated C++ decode, which is also valid Python, for every register value; + `expected` is the result for each value of the field `bits`.""" + code = compile(expr, expr, "eval") + for value in range(256): + status = [0] * 3 + status[reg] = value + result = eval(code, {"__builtins__": {}}, {"esphome__arg_status": status}) # noqa: S307 + assert result == expected[field_value(value, bits)], (expr, value) + + +def test_status_publishers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every decode matches the datasheet; one function per hub with entities, items in config order.""" + main_cpp = generate_main(component_config_path("hub_function.yaml")) + body = _body(main_cpp, "charger_a") + n_text = 2 * len(TEXT_SENSORS) + pairs = zip(body[0:n_text:2], body[1:n_text:2], strict=True) + text = [(LAST.fullmatch(last), TEXT.fullmatch(call)) for last, call in pairs] + assert [(m[1], t[1]) for m, t in text] == [(key, key) for key in TEXT_SENSORS] + strings = status_strings() + for _, call in text: + reg, bits, states = TEXT_SENSORS[call[1]] + # publish_status_text keeps the two low bits + expected = [strings.index(state) for state in states] + _check(f"{call[3]} + (({call[2]}) & 0x03)", reg, bits, expected) + binary = [BINARY.fullmatch(line) for line in body[n_text:]] + assert [m[1] for m in binary] == list(BINARY_SENSORS) + for match in binary: + reg, bits, on_values = BINARY_SENSORS[match[1]] + _check(f"({match[2]}) != 0", reg, bits, [v in on_values for v in range(4)]) + assert _body(main_cpp, "charger_b") == [ + "static uint8_t esphome__b_ts_status__last = 0xFF;", + "bq25186::publish_status_text(::b_ts_status, esphome__b_ts_status__last, (esphome__arg_status[1] >> 3), 4);", + ] + for hub in ("charger_a", "charger_b"): + assert f"{hub}->set_status_publisher(esphome__{hub}__status_fn);" in main_cpp + assert "charger_c->set_status_publisher" not in main_cpp + + # The C++ tests run a copy of this function (parameter named status); it must decode the same + cpp = CPP_TEST.read_text().replace("status[", "esphome__arg_status[") + assert [(m[1].lower(), m[2]) for m in CPP_BINARY.finditer(cpp)] == [ + (m[1], m[2]) for m in binary + ] + assert [(m[1].lower(), m[2], m[3]) for m in CPP_TEXT.finditer(cpp)] == [ + (t[1], t[2], t[3]) for _, t in text + ] + + +def test_single_bit_shortcut_needs_a_one_bit_field() -> None: + """A wider field whose match looks like a single bit keeps the bitmap form.""" + for mask, match in ((0x01, 0b10), (0x01, 0b01), (0x03, 0b10), (0x03, 0b0110)): + expr = str(_state_expression(0, 2, mask, match)) + for value in range(256): + status = [value] + result = eval(expr, {"__builtins__": {}}, {"esphome__arg_status": status}) # noqa: S307 + assert (result != 0) == bool((match >> ((value >> 2) & mask)) & 1), ( + mask, + match, + value, + ) diff --git a/tests/component_tests/bq25186/test_status_fields.py b/tests/component_tests/bq25186/test_status_fields.py new file mode 100644 index 00000000000..5fbc8cde68c --- /dev/null +++ b/tests/component_tests/bq25186/test_status_fields.py @@ -0,0 +1,26 @@ +"""Check the status entity field tables against the datasheet.""" + +from esphome.components.bq25186 import binary_sensor, text_sensor + +from .status_datasheet import BINARY_SENSORS, TEXT_SENSORS, field_value, status_strings + + +def test_binary_sensor_fields_match_the_datasheet() -> None: + assert [key for key, *_ in binary_sensor._FIELDS] == list(BINARY_SENSORS) + for key, reg, shift, mask, match, _ in binary_sensor._FIELDS: + sheet_reg, bits, on_values = BINARY_SENSORS[key] + assert reg == sheet_reg, key + for value in range(256): + decoded = bool((match >> ((value >> shift) & mask)) & 1) + assert decoded == (field_value(value, bits) in on_values), (key, value) + + +def test_text_sensor_fields_match_the_datasheet() -> None: + strings = status_strings() + assert [key for key, *_ in text_sensor._FIELDS] == list(TEXT_SENSORS) + for key, reg, shift, first in text_sensor._FIELDS: + sheet_reg, bits, states = TEXT_SENSORS[key] + assert reg == sheet_reg, key + for value in range(256): + decoded = strings[first + ((value >> shift) & 0x03)] + assert decoded == states[field_value(value, bits)], (key, value) diff --git a/tests/component_tests/canbus/test_canbus_payload_tables.py b/tests/component_tests/canbus/test_canbus_payload_tables.py index 958ec082e45..ab03e2d122d 100644 --- a/tests/component_tests/canbus/test_canbus_payload_tables.py +++ b/tests/component_tests/canbus/test_canbus_payload_tables.py @@ -1,13 +1,16 @@ -"""Tests for canbus constant payloads in shared PROGMEM tables.""" +"""Tests for canbus.send payloads and the CAN FD options.""" from collections.abc import Callable from pathlib import Path import re import pytest +import yaml -from esphome.components.canbus import CANBUS_SEND_ACTION_SCHEMA +from esphome.components import canbus +from esphome.config import read_config import esphome.config_validation as cv +from esphome.core import CORE def test_constant_payloads_share_progmem_tables( @@ -25,17 +28,103 @@ def test_constant_payloads_share_progmem_tables( ] assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2 assert "set_data_template(" in main_cpp + for setter in ( + "set_default_fd", + "set_supports_fd", + "set_default_bit_rate_switch", + "set_fd", + "set_bit_rate_switch", + "set_pad_data", + ): + assert f"->{setter}(" not in main_cpp + assert "USE_CANBUS_FD" not in {define.name for define in CORE.defines} + + +def _read_send_config( + send: dict, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> tuple[bool, str]: + config = { + "esphome": { + "name": "test", + "on_boot": [{"then": [{"canbus.send": send}]}], + }, + "esp32": {"board": "esp32dev"}, + "canbus": [ + { + "platform": "esp32_can", + "id": "can_bus", + "can_id": 4, + "rx_pin": 4, + "tx_pin": 5, + } + ], + } + path = tmp_path / "test.yaml" + path.write_text(yaml.safe_dump(config), encoding="utf-8") + CORE.config_path = path + result = read_config({}) + return result is not None, capsys.readouterr().out @pytest.mark.parametrize("size", [8, 9]) @pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n]) def test_send_one_frame_limit( - size: int, make: Callable[[int], list[int] | str] + size: int, + make: Callable[[int], list[int] | str], + tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: - """Payloads up to one 8 byte CAN frame are accepted, longer ones rejected.""" - config = {"canbus_id": "can_bus", "data": make(size)} - if size <= 8: - assert len(CANBUS_SEND_ACTION_SCHEMA(config)["data"]) == size + """Final validation limits classic CAN payloads to eight bytes.""" + valid, output = _read_send_config({"data": make(size)}, tmp_path, capsys) + assert valid is (size <= 8), output + if size > 8: + assert "Classic CAN data must be at most 8 bytes" in output + assert "canbus.send" in output + + +def test_classic_bus_rejects_fd_options( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """esp32_can does not support CAN FD, so its sends cannot ask for it.""" + valid, output = _read_send_config({"data": [1], "fd": False}, tmp_path, capsys) + assert not valid + assert "fd needs a canbus platform that supports CAN FD" in output + + +def test_only_the_fd_schema_has_bus_defaults() -> None: + """Classic platforms keep the dev schema; CAN FD platforms extend CANBUS_FD_SCHEMA.""" + config = {"can_id": 4} + assert canbus.CONF_DEFAULT_FD not in canbus.CANBUS_SCHEMA(config) + fd_config = canbus.CANBUS_FD_SCHEMA(config) + assert fd_config[canbus.CONF_DEFAULT_FD] is False + assert fd_config[canbus.CONF_DEFAULT_PAD_DATA] is False + + +FD_BUS = { + "default_fd": True, + "default_bit_rate_switch": False, + "default_pad_data": False, +} + + +@pytest.mark.parametrize( + ("action", "bus", "error"), + [ + ({"data": [0] * 64}, FD_BUS, None), + ({"data": [0] * 13}, FD_BUS, "requires pad_data"), + ({"data": [0] * 13, "pad_data": True}, FD_BUS, None), + ({"data": [0] * 13}, {**FD_BUS, "default_pad_data": True}, None), + ({"data": [0] * 9, "fd": False}, FD_BUS, "at most 8 bytes"), + ({"data": [0], "fd": False, "bit_rate_switch": True}, FD_BUS, "requires fd"), + ({"data": [], "remote_transmission_request": True}, FD_BUS, "cannot be used"), + ({"data": [0] * 9}, {**FD_BUS, "default_fd": False}, "at most 8 bytes"), + ], +) +def test_fd_send_validation(action: dict, bus: dict, error: str | None) -> None: + """Bus defaults apply to sends that leave the option out.""" + action = {"remote_transmission_request": False, **action} + if error is None: + canbus.validate_send(action, bus) else: - with pytest.raises(cv.Invalid, match="at most 8"): - CANBUS_SEND_ACTION_SCHEMA(config) + with pytest.raises(cv.Invalid, match=error): + canbus.validate_send(action, bus) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index c6fd1293f5e..dcebfa632fa 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -628,6 +628,32 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_gdey0583t81_defaults_and_driver_class( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The GDEY0583T81 models default to 648x480 and use the UC8179 driver class.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + for name in ("goodisplay-gdey0583t81-5.83", "0583BN-T81-D2"): + model = MODELS[name.upper()] + assert model.class_name == "EPaperUC8179" + config = run_schema_validation( + { + "id": "test_display", + "model": name, + "dc_pin": 21, + "full_update_every": 5, + } + ) + assert model.get_dimensions(config) == (648, 480) + + def test_uc8279_width_must_be_a_multiple_of_8( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], diff --git a/tests/component_tests/libretiny/test_crash_handler.py b/tests/component_tests/libretiny/test_crash_handler.py index 050f80f80d1..0837d75bbd1 100644 --- a/tests/component_tests/libretiny/test_crash_handler.py +++ b/tests/component_tests/libretiny/test_crash_handler.py @@ -33,6 +33,8 @@ def test_crash_handler_family_gate( defines = {define.name for define in CORE.defines} assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled assert ("USE_CRASH_HANDLER" in defines) is enabled + # __wrap_abort is only built with the crash handler; a leaked flag fails the link + assert ("-Wl,--wrap=abort" in CORE.build_flags) is enabled for trap in BK_TRAP_HANDLERS: assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is bk_hooks assert ( diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 4cbfcff5414..0400c8c68c0 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -161,3 +161,23 @@ def test_log_v2_only_on_native_toolchain( assert ("USE_ESP32_LOG_V2" in {d.name for d in CORE.defines}) is log_v2 sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert ("CONFIG_LOG_VERSION_2" in sdkconfig) is log_v2 + + +@pytest.mark.parametrize( + ("config", "usb_cdc"), + [ + pytest.param("test_logger_rp2_usb_cdc.yaml", True, id="usb_cdc"), + pytest.param("test_logger_rp2_uart0.yaml", False, id="uart0"), + ], +) +def test_rp2_usb_cdc_selection_define( + generate_main: Callable[[str | Path], str], + config: str, + usb_cdc: bool, +) -> None: + """The USB CDC crash replay only compiles in when the logger uses USB CDC.""" + generate_main(f"tests/component_tests/logger/{config}") + + assert ( + "USE_LOGGER_UART_SELECTION_USB_CDC" in {d.name for d in CORE.defines} + ) is usb_cdc diff --git a/tests/component_tests/logger/test_logger_rp2_uart0.yaml b/tests/component_tests/logger/test_logger_rp2_uart0.yaml new file mode 100644 index 00000000000..671f4a26610 --- /dev/null +++ b/tests/component_tests/logger/test_logger_rp2_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rp2040: + board: rpipico + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml b/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml new file mode 100644 index 00000000000..dc108720c0d --- /dev/null +++ b/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rp2040: + board: rpipico + +logger: diff --git a/tests/component_tests/mqtt/config/max_payload_size.yaml b/tests/component_tests/mqtt/config/max_payload_size.yaml new file mode 100644 index 00000000000..9e4241cae6b --- /dev/null +++ b/tests/component_tests/mqtt/config/max_payload_size.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test-device + +esp8266: + board: d1_mini_lite + +wifi: + ssid: MySSID + password: password1 + +mqtt: + broker: 192.168.1.2 + max_payload_size: 4kB diff --git a/tests/component_tests/mqtt/test_max_payload_size.py b/tests/component_tests/mqtt/test_max_payload_size.py new file mode 100644 index 00000000000..804b9950743 --- /dev/null +++ b/tests/component_tests/mqtt/test_max_payload_size.py @@ -0,0 +1,37 @@ +"""Tests for the mqtt max_payload_size option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONF_MAX_PAYLOAD_SIZE, CONFIG_SCHEMA +from esphome.const import CONF_BROKER, PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [("no_mac_suffix.yaml", 8192), ("max_payload_size.yaml", 4000)], +) +def test_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: int, +) -> None: + generate_main(component_config_path(config_file)) + defines = {d.name: str(d.value) for d in CORE.defines} + assert defines["MQTT_MAX_PAYLOAD_SIZE"] == str(expected) + + +@pytest.mark.parametrize("value", [0, 511, 1048577]) +def test_out_of_range_rejected( + set_core_config: SetCoreConfigCallable, value: int +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match=CONF_MAX_PAYLOAD_SIZE): + CONFIG_SCHEMA({CONF_BROKER: "192.168.1.2", CONF_MAX_PAYLOAD_SIZE: value}) diff --git a/tests/component_tests/remote_receiver/test_rc_switch_protocol.py b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py index 5c0d14c7945..aee308a2d54 100644 --- a/tests/component_tests/remote_receiver/test_rc_switch_protocol.py +++ b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py @@ -20,6 +20,6 @@ def test_rc_switch_protocols_share_flash_tables( # Receivers point into flash instead of holding a copy. assert "->set_protocol(&remote_base::RC_SWITCH_PROTOCOLS[1]);" in main_cpp assert "->set_protocol(rc_switch_custom_protocol_2);" in main_cpp - # Numbered and lambda protocols keep their existing paths. - assert "return remote_base::rc_switch_protocol(1);" in main_cpp + # A numbered protocol is copied out where it is sent; a lambda keeps its own path. + assert "remote_base::rc_switch_protocol(1).transmit(" in main_cpp assert "return remote_base::rc_switch_protocol(2);" in main_cpp diff --git a/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml b/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml new file mode 100644 index 00000000000..7820b217be5 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + id: tx + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Send + on_press: + - remote_transmitter.transmit_raw: + code: [1000, -1000] + code_storage_id: my_code diff --git a/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py index 1703ddf6ede..2726c5a532b 100644 --- a/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py +++ b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py @@ -6,8 +6,15 @@ import re import pytest +from esphome.components.remote_base import _rc_switch_bits, _rc_switch_letter_index + CONFIG = "raw_rc_switch_actions.yaml" +_TRANSMIT_BODY = re.compile( + r"auto esphome__call = ::tx->transmit\(\);\n(.*?)esphome__call\.perform\(\);", + re.DOTALL, +) + @pytest.fixture def main_cpp( @@ -19,117 +26,188 @@ def main_cpp( return re.sub(r"\n *(#line .*|// ESPHOME_LINE_DIRECTIVE_RESET)(?=\n)", "", main) -def _action(main_cpp: str, action_id: str) -> str: - """The setup statements of one action object.""" - marker = f"new({action_id}) " - start = main_cpp.index(marker) - # Ends at the next object constructed, or at the end of the file - end = main_cpp.find("new(", start + len(marker)) - return main_cpp[start : end if end != -1 else len(main_cpp)] +@pytest.fixture +def bodies(main_cpp: str) -> list[str]: + """The statements of each transmit: the script's two, then one per button.""" + found = _TRANSMIT_BODY.findall(main_cpp) + assert len(found) == 14 + return found -def test_raw_static_code_in_flash(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_2") +def _in_order(body: str, *parts: str) -> None: + positions = [body.index(part) for part in parts] + assert positions == sorted(positions) + + +def test_code_storage_id_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + generate_main(component_config_path("raw_code_storage_id.yaml")) + assert "transmit_raw ignores code_storage_id 'my_code'" in caplog.text + + +def test_no_action_objects(main_cpp: str) -> None: + assert "RawAction" not in main_cpp + assert "RCSwitch" not in main_cpp.replace("RCSwitchBase", "") + # The script's two transmits merge into one action; the buttons call functions directly + assert main_cpp.count("new(applyaction_id") == 1 + assert main_cpp.count("new(automation_id") == 1 + + +def test_raw_static_code_in_flash(main_cpp: str, bodies: list[str]) -> None: + # Both buttons send the same code from one flash table assert ( - "static constexpr int32_t int32_t_id_2[] PROGMEM = {1000, -1000, 500, -500};" - in raw + main_cpp.count( + "static constexpr int32_t remote_base_raw_code[] PROGMEM = " + "{1000, -1000, 500, -500};" + ) + == 1 ) - assert "->set_code_static(int32_t_id_2, 4);" in raw - assert "->set_carrier_frequency([]() -> uint32_t {\n return 0;\n});" in raw + raw = bodies[2] + assert ( + "remote_base::encode_raw_timings(esphome__call.get_data(), " + "::remote_base_raw_code, 4);" + ) in raw + # transmit() already resets the carrier frequency to 0 + assert "set_carrier_frequency" not in raw # Without repeat the call sends once assert "set_send_times" not in raw assert "set_send_wait" not in raw -def test_raw_repeat_and_carrier(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_3") - assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in raw - assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in raw - assert "->set_carrier_frequency([]() -> uint32_t {\n return 38000;\n});" in raw +def test_raw_repeat_and_carrier(bodies: list[str]) -> None: + _in_order( + bodies[3], + "encode_raw_timings(", + "set_carrier_frequency(38000);", + "esphome__call.set_send_times(2);", + "esphome__call.set_send_wait(10000);", + ) -def test_raw_lambda_code(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_4") +def test_raw_lambda_code(bodies: list[str]) -> None: assert ( - "->set_code_template([]() -> std::vector {\n" + "esphome__call.get_data()->set_data([]() -> std::vector {\n" " return {2000, -1000};\n" - "});" - ) in raw + " }());" + ) in bodies[4] -def test_rc_switch_raw(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchrawaction_id") - assert "return remote_base::rc_switch_protocol(2);" in constant - assert '->set_code(ESPHOME_F("00101001100111110101xxxx"));' in constant +def test_rc_switch_raw(bodies: list[str]) -> None: + # The code is decoded at build time; every character but 0 is a one + assert ( + "remote_base::rc_switch_protocol(2).transmit(esphome__call.get_data(), " + "2727775, 24);" + ) in bodies[5] # The rc_switch actions also send once without repeat - assert "set_send_times" not in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchrawaction_id_2") - assert "return remote_base::rc_switch_protocol(1);" in lambda_ - assert ' return std::string("0010100110");' in lambda_ - assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in lambda_ - assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in lambda_ + assert "set_send_times" not in bodies[5] + lambda_ = bodies[6] + _in_order( + lambda_, + "const std::string esphome__code = []() -> std::string {\n" + ' return std::string("0010100110");', + "remote_base::rc_switch_protocol(1).transmit(esphome__call.get_data(), " + "remote_base::decode_binary_string(esphome__code), esphome__code.size());", + "esphome__call.set_send_times(2);", + "esphome__call.set_send_wait(10000);", + ) -def test_rc_switch_type_a(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypeaaction_id") +@pytest.mark.parametrize( + ("code", "value"), + [("0", 0), ("1", 1), ("x", 1), ("10x0", 0b1010), ("1" * 64, 2**64 - 1)], +) +def test_rc_switch_bits_match_decode_binary_string(code: str, value: int) -> None: + assert _rc_switch_bits(code) == value + + +@pytest.mark.parametrize(("letter", "index"), [("a", 0), ("C", 2), ("p", 15)]) +def test_rc_switch_letter_index_matches_tolower(letter: str, index: int) -> None: + assert _rc_switch_letter_index(letter) == index + + +def test_rc_switch_type_a(bodies: list[str]) -> None: assert ( - "return remote_base::rc_switch_protocol_copy(rc_switch_custom_protocol);" - in constant - ) - assert '->set_group(ESPHOME_F("11001"));' in constant - assert '->set_device(ESPHOME_F("01000"));' in constant - assert "->set_state([]() -> bool {\n return true;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypeaaction_id_2") - assert '->set_group([]() -> std::string {\n return std::string("11001");' in ( - lambda_ - ) - assert '->set_device([]() -> std::string {\n return std::string("01000");' in ( - lambda_ - ) - assert "->set_state([]() -> bool {\n return false;" in lambda_ - - -def test_rc_switch_type_b(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypebaction_id_2") - assert "->set_address([]() -> uint8_t {\n return 4;\n});" in constant - assert "->set_channel([]() -> uint8_t {\n return 2;\n});" in constant - assert "->set_state([]() -> bool {\n return true;\n});" in constant - - -def test_rc_switch_type_c(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypecaction_id") - assert '->set_family(ESPHOME_F("c"));' in constant - assert "->set_group([]() -> uint8_t {\n return 1;\n});" in constant - assert "->set_device([]() -> uint8_t {\n return 2;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypecaction_id_2") - assert '->set_family([]() -> std::string {\n return std::string("C");' in ( - lambda_ - ) - - -def test_rc_switch_type_d(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypedaction_id") - assert '->set_group(ESPHOME_F("b"));' in constant - assert "->set_device([]() -> uint8_t {\n return 3;\n});" in constant - assert "->set_state([]() -> bool {\n return false;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypedaction_id_2") - assert '->set_group([]() -> std::string {\n return std::string("b");' in lambda_ - - -def test_lambdas_take_args_by_value(main_cpp: str) -> None: - """User lambdas get the script argument by value, so they may modify it.""" - raw = _action(main_cpp, "remote_base_rawaction_id") + "remote_base::RCSwitchBase::type_a_code(25, 8, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[7] assert ( - "[](int x) -> std::vector {\n x *= 2;\n return {x, -1000};\n}" - in raw + "remote_base::rc_switch_protocol_copy(rc_switch_custom_protocol).transmit(" + "esphome__call.get_data(), esphome__code, esphome__nbits);" + ) in bodies[7] + _in_order( + bodies[8], + "const auto esphome__group = remote_base::decode_binary_string(" + '[]() -> std::string {\n return std::string("11001");', + "const auto esphome__device = remote_base::decode_binary_string(", + "const auto esphome__state = []() -> bool {\n return false;", + "remote_base::RCSwitchBase::type_a_code(esphome__group, esphome__device, " + "esphome__state, &esphome__code, &esphome__nbits);", + "remote_base::rc_switch_protocol(1).transmit(", ) - assert "[](int x) -> uint32_t {\n return x *= 1000;\n}" in raw - assert "[](int x) -> uint32_t {\n return x *= 1;\n}" in raw - assert "[](int x) -> uint32_t {\n x *= 3; return x;\n}" in raw - type_b = _action(main_cpp, "remote_base_rcswitchtypebaction_id") - assert "[](int x) -> uint8_t {\n x *= 2; return x;\n}" in type_b - assert "[](int x) -> uint8_t {\n return x *= 1;\n}" in type_b + + +def test_rc_switch_type_b(bodies: list[str]) -> None: assert ( + "remote_base::RCSwitchBase::type_b_code(4, 2, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[9] + + +def test_rc_switch_type_c(bodies: list[str]) -> None: + assert ( + "remote_base::RCSwitchBase::type_c_code(2, 1, 2, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[10] + _in_order( + bodies[11], + "const auto esphome__family = static_cast(tolower(" + "[]() -> std::string {\n return std::string(\"C\");\n }()[0]) - 'a');", + "const auto esphome__group = []() -> uint8_t {", + "const auto esphome__device = []() -> uint8_t {", + "const auto esphome__state = []() -> bool {", + "remote_base::RCSwitchBase::type_c_code(esphome__family, esphome__group, " + "esphome__device, esphome__state, &esphome__code, &esphome__nbits);", + ) + + +def test_rc_switch_type_d(bodies: list[str]) -> None: + assert ( + "remote_base::RCSwitchBase::type_d_code(1, 3, false, " + "&esphome__code, &esphome__nbits);" + ) in bodies[12] + assert ( + "const auto esphome__group = static_cast(tolower(" + "[]() -> std::string {\n return std::string(\"b\");\n }()[0]) - 'a');" + ) in bodies[13] + + +def test_lambdas_take_args_by_value(bodies: list[str]) -> None: + """User lambdas get the script argument by value, so they may modify it. + + They are called rather than reduced, so even a single return modifies only its own copy. + """ + raw, type_b = bodies[0], bodies[1] + _in_order( + raw, + "set_data([](int x) -> std::vector {\n" + " x *= 2;\n return {x, -1000};\n }(x));", + "set_carrier_frequency([](int x) -> uint32_t {\n" + " return x *= 1000;\n }(x));", + "set_send_times([](int x) -> uint32_t {\n return x *= 1;\n }(x));", + "set_send_wait([](int x) -> uint32_t {\n x *= 3; return x;\n }(x));", + ) + _in_order( + type_b, + "const auto esphome__address = [](int x) -> uint8_t {\n" + " x *= 2; return x;\n }(x);", + "const auto esphome__channel = [](int x) -> uint8_t {\n" + " return x *= 1;\n }(x);", + "remote_base::RCSwitchBase::type_b_code(", + # The protocol is read last, as before "[](int x) -> remote_base::RCSwitchBase {\n" - " x *= 1; return remote_base::rc_switch_protocol(x);\n}" - ) in type_b + " x *= 1; return remote_base::rc_switch_protocol(x);\n" + " }(x).transmit(esphome__call.get_data(), esphome__code, esphome__nbits);", + ) diff --git a/tests/component_tests/sy6970/__init__.py b/tests/component_tests/sy6970/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sy6970/config/hub_function.yaml b/tests/component_tests/sy6970/config/hub_function.yaml new file mode 100644 index 00000000000..c0dc91a5e2a --- /dev/null +++ b/tests/component_tests/sy6970/config/hub_function.yaml @@ -0,0 +1,59 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sy6970: + - id: charger + - id: idle_charger + address: 0x6B + +binary_sensor: + - platform: sy6970 + sy6970_id: charger + vbus_connected: + name: VBUS Connected + id: vbus_connected + charging: + name: Charging + id: charging + charge_done: + name: Charge Done + id: charge_done + +sensor: + - platform: sy6970 + sy6970_id: charger + vbus_voltage: + name: VBUS Voltage + id: vbus_voltage + battery_voltage: + name: Battery Voltage + id: battery_voltage + system_voltage: + name: System Voltage + id: system_voltage + charge_current: + name: Charge Current + id: charge_current + precharge_current: + name: Precharge Current + id: precharge_current + +text_sensor: + - platform: sy6970 + sy6970_id: charger + bus_status: + name: Bus Status + id: bus_status + charge_status: + name: Charge Status + id: charge_status + ntc_status: + name: NTC Status + id: ntc_status diff --git a/tests/component_tests/sy6970/test_hub_function.py b/tests/component_tests/sy6970/test_hub_function.py new file mode 100644 index 00000000000..a9ff78f756d --- /dev/null +++ b/tests/component_tests/sy6970/test_hub_function.py @@ -0,0 +1,63 @@ +"""Tests for the function that publishes the sy6970 child entities.""" + +from collections.abc import Callable +from pathlib import Path + +REG = "esphome__arg_data.registers" +STATUS = f"{REG}[sy6970::SY6970_REG_STATUS]" + +# Every child, in config order, with its register decode +EXPECTED_BODY = [ + f"::vbus_connected->publish_state(((({STATUS} >> 5) & 7) != sy6970::BUS_STATUS_NO_INPUT));", + f"::charging->publish_state(sy6970::is_charging((({STATUS} >> 3) & 3)));", + f"::charge_done->publish_state(((({STATUS} >> 3) & 3) == sy6970::CHARGE_STATUS_CHARGE_DONE));", + ( + "::vbus_voltage->publish_state(((sy6970::VBUS_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_VBUS_VOLTAGE] & 127) * sy6970::VBUS_STEP_MV)) * 0.001f));" + ), + ( + "::battery_voltage->publish_state(((sy6970::VBAT_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_BATV] & 127) * sy6970::VBAT_STEP_MV)) * 0.001f));" + ), + ( + "::system_voltage->publish_state(((sy6970::VSYS_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_VINDPM_STATUS] & 127) * sy6970::VSYS_STEP_MV)) * 0.001f));" + ), + ( + "::charge_current->publish_state(((" + f"{REG}[sy6970::SY6970_REG_CHARGE_CURRENT_MONITOR] & 127) * sy6970::CHG_CURRENT_STEP_MA));" + ), + ( + "::precharge_current->publish_state((sy6970::PRE_CHG_BASE_MA + " + f"((({REG}[sy6970::SY6970_REG_PRECHARGE_CURRENT] >> 4) & 15) * sy6970::PRE_CHG_STEP_MA)));" + ), + ( + "::bus_status->publish_state(sy6970::BusStatusStrings::get_progmem_str(" + f"(({STATUS} >> 5) & 7), sy6970::BusStatusStrings::LAST_INDEX));" + ), + ( + "::charge_status->publish_state(sy6970::ChargeStatusStrings::get_progmem_str(" + f"(({STATUS} >> 3) & 3), sy6970::ChargeStatusStrings::LAST_INDEX));" + ), + ( + "::ntc_status->publish_state(sy6970::NtcStatusStrings::get_progmem_str(" + f"({REG}[sy6970::SY6970_REG_FAULT] & 7), sy6970::NtcStatusStrings::LAST_INDEX));" + ), +] + + +def test_children_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + head = ( + "static void esphome__charger__data_fn(" + "const sy6970::SY6970Data & esphome__arg_data) {\n" + ) + body = main_cpp.split(head, 1)[1].split("\n}", 1)[0] + assert [line.strip() for line in body.splitlines()] == EXPECTED_BODY + assert "charger->set_data_publisher(esphome__charger__data_fn);" in main_cpp + # A hub without children keeps a null publisher + assert "idle_charger->set_data_publisher" not in main_cpp diff --git a/tests/component_tests/tca8418/config/hub_function.yaml b/tests/component_tests/tca8418/config/hub_function.yaml new file mode 100644 index 00000000000..70385a26b73 --- /dev/null +++ b/tests/component_tests/tca8418/config/hub_function.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +tca8418: + - id: keypad + rows: 2 + columns: 2 + keys: "1234" + - id: idle_keypad + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: keypad + key: "4" + id: key_four + - platform: tca8418 + keypad_id: keypad + row: 0 + col: 0 + id: key_one diff --git a/tests/component_tests/tca8418/test_hub_function.py b/tests/component_tests/tca8418/test_hub_function.py new file mode 100644 index 00000000000..378a121dbc1 --- /dev/null +++ b/tests/component_tests/tca8418/test_hub_function.py @@ -0,0 +1,22 @@ +"""Tests for the function that publishes the tca8418 binary sensors.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_binary_sensors_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + assert ( + "static void esphome__keypad__key_fn(uint8_t esphome__arg_key, " + "bool esphome__arg_pressed) {\n" + " tca8418::publish_key(::key_four, esphome__arg_key, 12, esphome__arg_pressed);\n" + " tca8418::publish_key(::key_one, esphome__arg_key, 1, esphome__arg_pressed);\n" + "}" + ) in main_cpp + assert "keypad->set_key_publisher(esphome__keypad__key_fn);" in main_cpp + # A keypad without binary sensors keeps a null publisher + assert "idle_keypad->set_key_publisher" not in main_cpp diff --git a/tests/component_tests/tm1638/__init__.py b/tests/component_tests/tm1638/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tm1638/config/hub_function.yaml b/tests/component_tests/tm1638/config/hub_function.yaml new file mode 100644 index 00000000000..2b699e6ddc7 --- /dev/null +++ b/tests/component_tests/tm1638/config/hub_function.yaml @@ -0,0 +1,27 @@ +esphome: + name: test + +esp32: + board: esp32dev + +display: + - platform: tm1638 + id: panel + clk_pin: GPIO16 + stb_pin: GPIO17 + dio_pin: GPIO18 + - platform: tm1638 + id: idle_panel + clk_pin: GPIO19 + stb_pin: GPIO21 + dio_pin: GPIO22 + +binary_sensor: + - platform: tm1638 + tm1638_id: panel + id: button0 + key: 0 + - platform: tm1638 + tm1638_id: panel + id: button7 + key: 7 diff --git a/tests/component_tests/tm1638/test_hub_function.py b/tests/component_tests/tm1638/test_hub_function.py new file mode 100644 index 00000000000..5f99889217d --- /dev/null +++ b/tests/component_tests/tm1638/test_hub_function.py @@ -0,0 +1,21 @@ +"""Tests for the function that publishes the tm1638 key binary sensors.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_keys_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + assert ( + "static void esphome__panel__keys_fn(uint8_t esphome__arg_keys) {\n" + " tm1638::publish_key(::button0, esphome__arg_keys, 0);\n" + " tm1638::publish_key(::button7, esphome__arg_keys, 7);\n" + "}" + ) in main_cpp + assert "panel->set_keys_publisher(esphome__panel__keys_fn);" in main_cpp + # A panel without keys keeps a null publisher, so its loop reads nothing + assert "idle_panel->set_keys_publisher" not in main_cpp diff --git a/tests/component_tests/web_server/config/allowed_origins.yaml b/tests/component_tests/web_server/config/allowed_origins.yaml index 2ced49d6d8c..87354d42e65 100644 --- a/tests/component_tests/web_server/config/allowed_origins.yaml +++ b/tests/component_tests/web_server/config/allowed_origins.yaml @@ -12,4 +12,4 @@ wifi: web_server: allowed_origins: - https://app.esphome.io - - "*" + - http://other.local:8080 diff --git a/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml b/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml new file mode 100644 index 00000000000..2ced49d6d8c --- /dev/null +++ b/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + allowed_origins: + - https://app.esphome.io + - "*" diff --git a/tests/component_tests/web_server/test_allowed_origins.py b/tests/component_tests/web_server/test_allowed_origins.py index 7a6c602c129..f95d1f260f1 100644 --- a/tests/component_tests/web_server/test_allowed_origins.py +++ b/tests/component_tests/web_server/test_allowed_origins.py @@ -2,6 +2,13 @@ from collections.abc import Callable from pathlib import Path +import re + +from esphome.core import CORE + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} def test_allowed_origins_are_a_progmem_table( @@ -13,6 +20,25 @@ def test_allowed_origins_are_a_progmem_table( assert ( "static constexpr const char * web_server_allowed_origins[] PROGMEM = " - '{"https://app.esphome.io", "*", nullptr};' in main_cpp + '{"https://app.esphome.io", "http://other.local:8080", nullptr};' in main_cpp ) - assert "->set_allowed_origins(web_server_allowed_origins);" in main_cpp + assert "USE_WEBSERVER_ALLOWED_ORIGINS" in _defines() + assert "USE_WEBSERVER_ALLOW_ANY_ORIGIN" not in _defines() + # Set on web_server_base, whose middleware checks every handler added with add_handler() + assert re.search( + r"\bweb_server_base_\w+->set_allowed_origins\(web_server_allowed_origins\);", + main_cpp, + ) + + +def test_wildcard_origin_stores_no_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A "*" entry allows any origin at build time, so no table or list check is generated.""" + main_cpp = generate_main(component_config_path("allowed_origins_wildcard.yaml")) + + assert "USE_WEBSERVER_ALLOW_ANY_ORIGIN" in _defines() + assert "USE_WEBSERVER_ALLOWED_ORIGINS" not in _defines() + assert "web_server_allowed_origins" not in main_cpp + assert "set_allowed_origins" not in main_cpp diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py index 87911c5f9b7..8a1174f3ecc 100644 --- a/tests/component_tests/web_server/test_private_network_access.py +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -8,6 +8,7 @@ from esphome.components.web_server import ( validate_origin, validate_private_network_access, ) +from esphome.components.web_server_base import MAX_ORIGIN_LENGTH from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS from esphome.types import ConfigType @@ -84,3 +85,12 @@ def test_validate_origin_invalid(value: str) -> None: """Malformed origins are rejected at config time instead of silently 403ing.""" with pytest.raises(cv.Invalid, match="not a valid origin"): validate_origin(value) + + +def test_validate_origin_length_limit() -> None: + """An origin longer than the ESP32 header buffer could never match, so it is rejected.""" + prefix = "https://" + longest = prefix + "a" * (MAX_ORIGIN_LENGTH - len(prefix)) + assert validate_origin(longest) == longest + with pytest.raises(cv.Invalid, match="too long"): + validate_origin(longest + "a") diff --git a/tests/components/bme680_bsec/test_publish.cpp b/tests/components/bme680_bsec/test_publish.cpp index bb0a7872fec..d061a6b4460 100644 --- a/tests/components/bme680_bsec/test_publish.cpp +++ b/tests/components/bme680_bsec/test_publish.cpp @@ -181,8 +181,21 @@ TEST_F(BSECPublishTest, NewReadingBeforeTheLastWentOut) { output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.0f), output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 41.0f), }); - // Both readings go out in full, in arrival order. - EXPECT_EQ(this->passes(4), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); + // A state still waiting keeps its place and takes the newer value; the others queue behind it. + EXPECT_EQ(this->passes(3), (Passes{{"humidity=41"}, {"temperature=21"}, {}})); +} + +TEST_F(BSECPublishTest, WaitingAccuracyTakesTheNewerReading) { + this->attach_all(); + this->component.set_state_save_interval(0); + this->arm({output(BSEC_OUTPUT_IAQ, 30.0f, 1)}); + EXPECT_EQ(this->pass(), (std::vector{"iaq=30"})); + this->arm({output(BSEC_OUTPUT_IAQ, 31.0f, 3)}); + // The accuracy and save slots still waiting go out once, with the newer accuracy; IAQ queues behind them. + EXPECT_EQ(this->passes(3), (Passes{{"iaq_accuracy_text=Calibrated"}, {"iaq_accuracy=3"}, {}})); + EXPECT_EQ(bsec_double::get_state_calls, 1); + EXPECT_EQ(this->passes(2), (Passes{{"iaq=31"}, {}})); + EXPECT_EQ(bsec_double::get_state_calls, 1); } } // namespace diff --git a/tests/components/bme68x_bsec2/test_publish.cpp b/tests/components/bme68x_bsec2/test_publish.cpp index 3669b2323bb..dfba5c30c18 100644 --- a/tests/components/bme68x_bsec2/test_publish.cpp +++ b/tests/components/bme68x_bsec2/test_publish.cpp @@ -16,10 +16,17 @@ class TestBSEC2 : public BME68xBSEC2Component { public: uint32_t get_hash() override { return 0; } using BME68xBSEC2Component::publish_; + using BME68xBSEC2Component::PUBLISH_QUEUE_SIZE; // setup() leaves the loop disabled until the first reading. void start_idle() { this->disable_loop(); } }; +std::string format_state(const char *name, float value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s=%g", name, value); + return buf; +} + bsec_output_t output(uint8_t sensor_id, float signal, uint8_t accuracy = 0) { bsec_output_t out{}; out.sensor_id = sensor_id; @@ -89,11 +96,7 @@ class BSEC2PublishTest : public ::testing::Test { private: void add_(sensor::Sensor &sensor, const char *name) { - sensor.add_on_state_callback([this, name](float value) { - char buf[64]; - snprintf(buf, sizeof(buf), "%s=%g", name, value); - this->events.push_back(buf); - }); + sensor.add_on_state_callback([this, name](float value) { this->events.push_back(format_state(name, value)); }); } }; @@ -220,6 +223,58 @@ TEST_F(BSEC2PublishTest, NewReadingBeforeTheLastWentOut) { EXPECT_EQ(this->drain(), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); } +TEST_F(BSEC2PublishTest, ParallelModeFieldsAllGoOutInOrder) { + this->attach_all(); + static constexpr const char *STATES[] = {"Stabilizing", "Uncertain", "Calibrating"}; + // One read in parallel mode returns up to 3 fields, each published in full before loop() runs. + Passes expected; + for (int field = 0; field < 3; field++) { + const float f = field; + this->publish({ + output(BSEC_OUTPUT_IAQ, 50.0f + f, field), + output(BSEC_OUTPUT_STATIC_IAQ, 40.0f + f), + output(BSEC_OUTPUT_CO2_EQUIVALENT, 600.0f + f), + output(BSEC_OUTPUT_BREATH_VOC_EQUIVALENT, 1.0f + f), + output(BSEC_OUTPUT_RAW_PRESSURE, 100000.0f + 100.0f * f), + output(BSEC_OUTPUT_RAW_GAS, 120000.0f + f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 20.0f + f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 45.0f + f), + }); + for (const auto &[name, value] : std::initializer_list>{ + {"iaq", 50 + f}, + {"iaq_static", 40 + f}, + {"co2_equivalent", 600 + f}, + {"breath_voc_equivalent", 1 + f}, + {"pressure", 1000 + f}, + {"gas_resistance", 120000 + f}, + {"temperature", 20 + f}, + {"humidity", 45 + f}, + {"iaq_accuracy", f}, + }) { + expected.push_back({format_state(name, value)}); + } + expected.push_back({std::string("iaq_accuracy_text=") + STATES[field]}); + expected.push_back({}); // state save opportunity + } + expected.push_back({}); + // 3 full fields fill the queue exactly; nothing is dropped. + ASSERT_EQ(expected.size(), TestBSEC2::PUBLISH_QUEUE_SIZE + 1u); + EXPECT_EQ(this->drain(), expected); +} + +TEST_F(BSEC2PublishTest, FullQueueDropsTheOldestState) { + this->component.set_temperature_sensor(&this->temperature); + const int size = TestBSEC2::PUBLISH_QUEUE_SIZE; + // One state more than fits pushes out the first. + for (int i = 0; i <= size; i++) + this->publish({output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, static_cast(i))}); + auto passes = this->drain(); + ASSERT_EQ(passes.size(), static_cast(size) + 1); + EXPECT_EQ(passes.front(), (std::vector{"temperature=1"})); + EXPECT_EQ(passes[size - 1], (std::vector{format_state("temperature", size)})); + EXPECT_TRUE(passes.back().empty()); +} + } // namespace } // namespace esphome::bme68x_bsec2::testing #endif // USE_BSEC2 diff --git a/tests/components/bq25186/__init__.py b/tests/components/bq25186/__init__.py index 03731db0eb0..23e601a696f 100644 --- a/tests/components/bq25186/__init__.py +++ b/tests/components/bq25186/__init__.py @@ -4,11 +4,22 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # One empty config entry so to_code runs and compiles the PG/GPO switch path. + # One empty config entry so to_code runs and compiles the PG/GPO switch and status entity paths. manifest.multi_conf = False - manifest.dependencies = manifest.dependencies + ["i2c", "switch"] + manifest.dependencies = manifest.dependencies + [ + "binary_sensor", + "i2c", + "switch", + "text_sensor", + ] async def to_code(config: ConfigType) -> None: cg.add_define("USE_BQ25186_PG_GPO") + cg.add_define("USE_BINARY_SENSOR") + cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK") + cg.add_define("ESPHOME_ENTITY_BINARY_SENSOR_COUNT", 0) + cg.add_define("USE_TEXT_SENSOR") + cg.add_define("ESPHOME_ENTITY_TEXT_SENSOR_COUNT", 0) + cg.add_define("USE_BQ25186_STATUS_PUBLISHER") manifest.to_code = to_code diff --git a/tests/components/bq25186/common.h b/tests/components/bq25186/common.h new file mode 100644 index 00000000000..83a78860265 --- /dev/null +++ b/tests/components/bq25186/common.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include "esphome/components/bq25186/bq25186.h" + +namespace esphome::bq25186::testing { + +static constexpr uint8_t ADDRESS = 0x6A; + +// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size > 1 && this->fail_writes) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (out_size != 0 && this->fail_reads) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + bool fail_writes{false}; + bool fail_reads{false}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +} // namespace esphome::bq25186::testing diff --git a/tests/components/bq25186/test_bq25186_pg_gpo.cpp b/tests/components/bq25186/test_bq25186_pg_gpo.cpp index c1296c9e6e5..9a596c91780 100644 --- a/tests/components/bq25186/test_bq25186_pg_gpo.cpp +++ b/tests/components/bq25186/test_bq25186_pg_gpo.cpp @@ -1,42 +1,10 @@ -#include - -#include - -#include "esphome/components/bq25186/bq25186.h" +#include "common.h" #include "esphome/components/switch/switch.h" namespace esphome::bq25186::testing { -static constexpr uint8_t ADDRESS = 0x6A; static constexpr uint8_t SYS_REG_OTHER_BITS = 0x0F; -// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail. -class RegisterBus final : public i2c::I2CBus { - public: - i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, - size_t out_size) override { - EXPECT_EQ(address, ADDRESS); - if (size > 1 && this->fail_writes) - return i2c::ERROR_NOT_ACKNOWLEDGED; - if (size != 0) { - this->pointer_ = data[0]; - for (size_t i = 1; i < size; i++) - this->registers[this->next_()] = data[i]; - } - for (size_t i = 0; i < out_size; i++) - out[i] = this->registers[this->next_()]; - return i2c::ERROR_OK; - } - - std::array registers{}; - bool fail_writes{false}; - - protected: - size_t next_() { return this->pointer_++ % this->registers.size(); } - - uint8_t pointer_{0}; -}; - // The body code generation emits for apply= with publish="on_success" static BQ25186Component *hub_under_test = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static void apply_pg_gpo(switch_::Switch *entity, bool state) { diff --git a/tests/components/bq25186/test_bq25186_status.cpp b/tests/components/bq25186/test_bq25186_status.cpp new file mode 100644 index 00000000000..a857bea8973 --- /dev/null +++ b/tests/components/bq25186/test_bq25186_status.cpp @@ -0,0 +1,228 @@ +#include "common.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::bq25186::testing { + +enum BinaryIndex : uint8_t { + TS, + CHARGING, + CHARGE_DONE, + ILIM, + VDPPM, + VINDPM, + THERMREG, + POWER_GOOD, + VIN_OVP, + BATTERY_UVLO, + SAFETY_TIMER_FLAG, + WAKE1_FLAG, + WAKE2_FLAG, + TS_FLAG, + ILIM_FLAG, + VDPPM_FLAG, + VINDPM_FLAG, + THERMREG_FLAG, + VIN_OVP_FLAG, + BATTERY_UVLO_FLAG, + BATTERY_OCP_FLAG, + BINARY_COUNT, +}; +enum TextIndex : uint8_t { CHARGE_STATUS, TS_STATUS, TEXT_COUNT }; + +// The register and bit each single-bit sensor reads, from the datasheet +struct SingleBit { + BinaryIndex sensor; + uint8_t reg; + uint8_t bit; +}; +static constexpr std::array SINGLE_BITS{{ + {TS, BQ25186_REG_STAT0, 0x80}, + {ILIM, BQ25186_REG_STAT0, 0x10}, + {VDPPM, BQ25186_REG_STAT0, 0x08}, + {VINDPM, BQ25186_REG_STAT0, 0x04}, + {THERMREG, BQ25186_REG_STAT0, 0x02}, + {POWER_GOOD, BQ25186_REG_STAT0, 0x01}, + {VIN_OVP, BQ25186_REG_STAT1, 0x80}, + {BATTERY_UVLO, BQ25186_REG_STAT1, 0x40}, + {SAFETY_TIMER_FLAG, BQ25186_REG_STAT1, 0x04}, + {WAKE1_FLAG, BQ25186_REG_STAT1, 0x02}, + {WAKE2_FLAG, BQ25186_REG_STAT1, 0x01}, + {TS_FLAG, BQ25186_REG_FLAG0, 0x80}, + {ILIM_FLAG, BQ25186_REG_FLAG0, 0x40}, + {VDPPM_FLAG, BQ25186_REG_FLAG0, 0x20}, + {VINDPM_FLAG, BQ25186_REG_FLAG0, 0x10}, + {THERMREG_FLAG, BQ25186_REG_FLAG0, 0x08}, + {VIN_OVP_FLAG, BQ25186_REG_FLAG0, 0x04}, + {BATTERY_UVLO_FLAG, BQ25186_REG_FLAG0, 0x02}, + {BATTERY_OCP_FLAG, BQ25186_REG_FLAG0, 0x01}, +}}; + +// Copy of the generated function for a hub with every status entity; +// tests/component_tests/bq25186/test_hub_function.py checks it against the generated code +struct StatusEntities { + std::array binary; + std::array text; + uint8_t charge_status_last{0xFF}; + uint8_t ts_status_last{0xFF}; +}; +static StatusEntities *entities = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static void publish_status(const BQ25186StatusRegisters &status) { + entities->binary[TS].publish_state(((status[0] >> 7) & 0x01)); + entities->binary[CHARGING].publish_state(((0b0110 >> ((status[0] >> 5) & 0x03)) & 1)); + entities->binary[CHARGE_DONE].publish_state(((0b1000 >> ((status[0] >> 5) & 0x03)) & 1)); + entities->binary[ILIM].publish_state(((status[0] >> 4) & 0x01)); + entities->binary[VDPPM].publish_state(((status[0] >> 3) & 0x01)); + entities->binary[VINDPM].publish_state(((status[0] >> 2) & 0x01)); + entities->binary[THERMREG].publish_state(((status[0] >> 1) & 0x01)); + entities->binary[POWER_GOOD].publish_state(((status[0] >> 0) & 0x01)); + entities->binary[VIN_OVP].publish_state(((status[1] >> 7) & 0x01)); + entities->binary[BATTERY_UVLO].publish_state(((status[1] >> 6) & 0x01)); + entities->binary[SAFETY_TIMER_FLAG].publish_state(((status[1] >> 2) & 0x01)); + entities->binary[WAKE1_FLAG].publish_state(((status[1] >> 1) & 0x01)); + entities->binary[WAKE2_FLAG].publish_state(((status[1] >> 0) & 0x01)); + entities->binary[TS_FLAG].publish_state(((status[2] >> 7) & 0x01)); + entities->binary[ILIM_FLAG].publish_state(((status[2] >> 6) & 0x01)); + entities->binary[VDPPM_FLAG].publish_state(((status[2] >> 5) & 0x01)); + entities->binary[VINDPM_FLAG].publish_state(((status[2] >> 4) & 0x01)); + entities->binary[THERMREG_FLAG].publish_state(((status[2] >> 3) & 0x01)); + entities->binary[VIN_OVP_FLAG].publish_state(((status[2] >> 2) & 0x01)); + entities->binary[BATTERY_UVLO_FLAG].publish_state(((status[2] >> 1) & 0x01)); + entities->binary[BATTERY_OCP_FLAG].publish_state(((status[2] >> 0) & 0x01)); + publish_status_text(&entities->text[CHARGE_STATUS], entities->charge_status_last, (status[0] >> 5), 0); + publish_status_text(&entities->text[TS_STATUS], entities->ts_status_last, (status[1] >> 3), 4); +} + +class BQ25186StatusTest : public ::testing::Test { + protected: + void SetUp() override { + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(ADDRESS); + this->hub_.set_status_publisher(publish_status); + entities = &this->entities_; + } + + binary_sensor::BinarySensor &binary_(size_t index) { return this->entities_.binary[index]; } + text_sensor::TextSensor &text_(size_t index) { return this->entities_.text[index]; } + + void poll_(uint8_t stat0, uint8_t stat1 = 0, uint8_t flag0 = 0) { + this->bus_.registers[BQ25186_REG_STAT0] = stat0; + this->bus_.registers[BQ25186_REG_STAT1] = stat1; + this->bus_.registers[BQ25186_REG_FLAG0] = flag0; + this->hub_.update(); + } + + RegisterBus bus_; + BQ25186Component hub_; + StatusEntities entities_; +}; + +TEST_F(BQ25186StatusTest, EachSingleBitSensorReadsOnlyItsBit) { + for (const SingleBit &field : SINGLE_BITS) { + std::array regs{}; + regs[field.reg] = field.bit; + this->poll_(regs[0], regs[1], regs[2]); + for (size_t i = 0; i < BINARY_COUNT; i++) { + SCOPED_TRACE(i); + EXPECT_TRUE(this->binary_(i).has_state()); + EXPECT_EQ(this->binary_(i).state, i == field.sensor); + } + } +} + +TEST_F(BQ25186StatusTest, SingleBitSensorsIgnoreTheOtherBits) { + for (const SingleBit &field : SINGLE_BITS) { + std::array regs{0xFF, 0xFF, 0xFF}; + regs[field.reg] = static_cast(~field.bit); + this->poll_(regs[0], regs[1], regs[2]); + SCOPED_TRACE(field.sensor); + EXPECT_FALSE(this->binary_(field.sensor).state); + } +} + +TEST_F(BQ25186StatusTest, ChargingAndChargeDoneDecodeTheChargeStatus) { + // STAT0[6:5]: not charging, constant current, constant voltage, done + static constexpr std::array CHARGING_ON{false, true, true, false}; + static constexpr std::array DONE_ON{false, false, false, true}; + for (uint8_t value = 0; value < 4; value++) { + SCOPED_TRACE(value); + this->poll_(static_cast(value << 5)); + EXPECT_EQ(this->binary_(CHARGING).state, CHARGING_ON[value]); + EXPECT_EQ(this->binary_(CHARGE_DONE).state, DONE_ON[value]); + this->poll_(static_cast((value << 5) | 0x9F)); + EXPECT_EQ(this->binary_(CHARGING).state, CHARGING_ON[value]); + EXPECT_EQ(this->binary_(CHARGE_DONE).state, DONE_ON[value]); + } +} + +TEST_F(BQ25186StatusTest, ChargeStatusText) { + static constexpr std::array NAMES{"Not Charging", "Constant Current", "Constant Voltage", + "Charge Done/Disabled"}; + for (uint8_t value = 0; value < 4; value++) { + this->poll_(static_cast((value << 5) | 0x9F), 0xFF, 0xFF); + EXPECT_EQ(this->text_(CHARGE_STATUS).state, NAMES[value]); + } +} + +TEST_F(BQ25186StatusTest, TsStatusText) { + static constexpr std::array NAMES{"Normal", "Hot/Cold (Charging Suspended)", + "Cool (Current Reduced)", "Warm (Voltage Reduced)"}; + for (uint8_t value = 0; value < 4; value++) { + this->poll_(0xFF, static_cast((value << 3) | 0xE7), 0xFF); + EXPECT_EQ(this->text_(TS_STATUS).state, NAMES[value]); + } +} + +TEST_F(BQ25186StatusTest, FirstPollPublishesTextForValueZero) { + int published = 0; + this->text_(CHARGE_STATUS).add_on_state_callback([&published](const std::string &) { published++; }); + this->poll_(0); + EXPECT_EQ(published, 1); + EXPECT_EQ(this->text_(CHARGE_STATUS).state, "Not Charging"); +} + +TEST_F(BQ25186StatusTest, TextPublishesOnlyWhenTheFieldChanges) { + int charge = 0; + int ts = 0; + this->text_(CHARGE_STATUS).add_on_state_callback([&charge](const std::string &) { charge++; }); + this->text_(TS_STATUS).add_on_state_callback([&ts](const std::string &) { ts++; }); + this->poll_(0x20, 0x08); + EXPECT_EQ(charge, 1); + EXPECT_EQ(ts, 1); + // Other bits change, the fields do not + this->poll_(0x3F, 0xE7 | 0x08, 0xFF); + EXPECT_EQ(charge, 1); + EXPECT_EQ(ts, 1); + this->poll_(0x40, 0x08); + EXPECT_EQ(charge, 2); + EXPECT_EQ(ts, 1); + this->poll_(0x40, 0x10); + EXPECT_EQ(charge, 2); + EXPECT_EQ(ts, 2); +} + +TEST_F(BQ25186StatusTest, BinarySensorsPublishEveryPoll) { + std::array published{}; + for (size_t i = 0; i < BINARY_COUNT; i++) + this->binary_(i).add_on_raw_state_callback([&published, i](bool) { published[i]++; }); + this->poll_(0); + this->poll_(0); + this->poll_(0xFF, 0xFF, 0xFF); + for (size_t i = 0; i < BINARY_COUNT; i++) { + SCOPED_TRACE(i); + EXPECT_EQ(published[i], 3); + } +} + +TEST_F(BQ25186StatusTest, FailedReadPublishesNothing) { + this->bus_.fail_reads = true; + this->poll_(0xFF, 0xFF, 0xFF); + for (size_t i = 0; i < BINARY_COUNT; i++) + EXPECT_FALSE(this->binary_(i).has_state()); + for (size_t i = 0; i < TEXT_COUNT; i++) + EXPECT_FALSE(this->text_(i).has_state()); +} + +} // namespace esphome::bq25186::testing diff --git a/tests/components/bthome_mithermometer/__init__.py b/tests/components/bthome_mithermometer/__init__.py new file mode 100644 index 00000000000..63589054d5f --- /dev/null +++ b/tests/components/bthome_mithermometer/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The replay check is compiled only when a sensor enables replay_protection; the + # unit-test build has no sensors, so emit the define here to put it under test. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/bthome_mithermometer/common-ln.yaml b/tests/components/bthome_mithermometer/common-ln.yaml index 947f8890f86..e2a85fd38e2 100644 --- a/tests/components/bthome_mithermometer/common-ln.yaml +++ b/tests/components/bthome_mithermometer/common-ln.yaml @@ -3,6 +3,7 @@ sensor: mac_address: A4:C1:38:4E:16:78 # bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif bindkey: eef418daf699a0c188f3bfd17e4565d9 + replay_protection: true temperature: name: BTHome Temperature humidity: diff --git a/tests/components/bthome_mithermometer/common.h b/tests/components/bthome_mithermometer/common.h new file mode 100644 index 00000000000..e0cdfffbc63 --- /dev/null +++ b/tests/components/bthome_mithermometer/common.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "esphome/components/bthome_mithermometer/bthome_ble.h" + +namespace esphome::bthome_mithermometer::testing { + +static constexpr uint64_t SENSOR_ADDRESS = 0xA4C1384E1678ULL; + +// BTHome v2 service data advertisement (UUID 0xFCD2) sent from `address`. +inline ble_device_base::ESPBTDevice advert(const uint8_t *service_data, size_t size, + uint64_t address = SENSOR_ADDRESS) { + std::vector adv = {static_cast(size + 3), 0x16, 0xD2, 0xFC}; + adv.insert(adv.end(), service_data, service_data + size); + uint8_t mac[6]; + for (size_t i = 0; i < 6; i++) + mac[i] = static_cast(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); + return device; +} + +inline ble_device_base::ESPBTDevice advert(std::initializer_list service_data, + uint64_t address = SENSOR_ADDRESS) { + return advert(service_data.begin(), service_data.size(), address); +} + +template +inline ble_device_base::ESPBTDevice advert(const std::array &service_data, + uint64_t address = SENSOR_ADDRESS) { + return advert(service_data.data(), N, address); +} + +} // namespace esphome::bthome_mithermometer::testing diff --git a/tests/components/bthome_mithermometer/common.yaml b/tests/components/bthome_mithermometer/common.yaml index d61738bbe51..f70d8997344 100644 --- a/tests/components/bthome_mithermometer/common.yaml +++ b/tests/components/bthome_mithermometer/common.yaml @@ -7,6 +7,7 @@ sensor: ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: eef418daf699a0c188f3bfd17e4565d9 + replay_protection: true temperature: name: "BTHome Temperature" humidity: diff --git a/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp b/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp index 2e3275cc015..0242721cc1e 100644 --- a/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp +++ b/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp @@ -1,29 +1,12 @@ #include -#include -#include -#include - -#include "esphome/components/bthome_mithermometer/bthome_ble.h" +#include "../common.h" namespace esphome::bthome_mithermometer::testing { namespace { -constexpr uint64_t SENSOR_ADDRESS = 0xA4C1384E1678ULL; - -// Unencrypted BTHome v2 service data (UUID 0xFCD2) sent from SENSOR_ADDRESS. -ble_device_base::ESPBTDevice advert(std::initializer_list service_data) { - std::vector adv = {static_cast(service_data.size() + 3), 0x16, 0xD2, 0xFC}; - adv.insert(adv.end(), service_data.begin(), service_data.end()); - uint8_t mac[6]; - for (size_t i = 0; i < 6; i++) - mac[i] = static_cast(SENSOR_ADDRESS >> (i * 8)); - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); - return device; -} - +// Service data in these tests is unencrypted BTHome v2. struct Harness { Harness() { this->thermometer.set_address(SENSOR_ADDRESS); diff --git a/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp b/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp new file mode 100644 index 00000000000..84f852a0cc1 --- /dev/null +++ b/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp @@ -0,0 +1,121 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::bthome_mithermometer::testing { + +// Ages the last accepted frame past the resync window; the host loop clock stays at 0. +class ReplayClockAccess { + public: + static void expire_window(BTHomeMiThermometer &thermometer) { + thermometer.last_accepted_ms_ = static_cast(0) - (10 * 60 * 1000 + 1); + } +}; + +namespace { + +// Encrypted BTHome v2 frames for SENSOR_ADDRESS, generated with Python `cryptography` +// AESCCM(tag_length=4) and the bindkey below. Layout: device info (0x41), ciphertext, +// counter (little-endian), MIC. Each plaintext is a single temperature object (0x02). +// Bindkey: eef418daf699a0c188f3bfd17e4565d9 +using Frame = std::array; + +// Counter 10, 22.50 C +constexpr Frame COUNTER_10 = {0x41, 0xB9, 0xBD, 0x25, 0x0A, 0x00, 0x00, 0x00, 0x47, 0x51, 0x79, 0x04}; +// Counter 11, 23.48 C +constexpr Frame COUNTER_11 = {0x41, 0xD8, 0x9D, 0x54, 0x0B, 0x00, 0x00, 0x00, 0x4F, 0x5D, 0x89, 0xF2}; +// Counter 11 again, 4.00 C +constexpr Frame COUNTER_11_OTHER = {0x41, 0xD8, 0x21, 0x5C, 0x0B, 0x00, 0x00, 0x00, 0x72, 0xF8, 0x2B, 0x55}; +// Counter 9, 4.00 C +constexpr Frame COUNTER_9 = {0x41, 0x17, 0xB6, 0x8D, 0x09, 0x00, 0x00, 0x00, 0xFB, 0x42, 0xC4, 0x2D}; + +// Counter 100 from another device sharing the bindkey (address SENSOR_ADDRESS + 1), 30.00 C +constexpr uint64_t OTHER_ADDRESS = SENSOR_ADDRESS + 1; +constexpr Frame OTHER_COUNTER_100 = {0x41, 0x48, 0xD7, 0xD1, 0x64, 0x00, 0x00, 0x00, 0x09, 0x5B, 0xEC, 0x22}; + +struct Harness { + explicit Harness(bool replay_protection = true) { + this->thermometer.set_address(SENSOR_ADDRESS); + this->thermometer.set_bindkey( + {0xEE, 0xF4, 0x18, 0xDA, 0xF6, 0x99, 0xA0, 0xC1, 0x88, 0xF3, 0xBF, 0xD1, 0x7E, 0x45, 0x65, 0xD9}); + this->thermometer.set_replay_protection(replay_protection); + this->thermometer.set_temperature_sensor(&this->temperature); + } + + BTHomeMiThermometer thermometer; + sensor::Sensor temperature; +}; + +} // namespace + +TEST(BTHomeMiThermometerReplay, AcceptsIncreasingCounters) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_NEAR(h.temperature.state, 23.48f, 0.001f); +} + +TEST(BTHomeMiThermometerReplay, DropsExactRepeat) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_11))); +} + +// Some firmwares (e.g. PVVX) send new data under the same counter. +TEST(BTHomeMiThermometerReplay, AcceptsNewDataWithSameCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11_OTHER))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +TEST(BTHomeMiThermometerReplay, RejectsLowerCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); +} + +// The first frame after boot has nothing to compare against and is accepted. +TEST(BTHomeMiThermometerReplay, AcceptsFirstFrameWithAnyCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +// Without replay_protection the counter is not checked at all. +TEST(BTHomeMiThermometerReplay, DisabledAcceptsLowerCounter) { + Harness h(false); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +// A frame from another device that decrypts with the same bindkey is rejected +// by the address check and must not move the replay counter. +TEST(BTHomeMiThermometerReplay, OtherDeviceDoesNotAffectCounter) { + Harness h; + EXPECT_FALSE(h.thermometer.parse_device(advert(OTHER_COUNTER_100, OTHER_ADDRESS))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); +} + +// A lower counter is accepted again once no frame has been accepted for ten minutes, so a sensor whose counter +// restarted recovers; the warning flag set by the rejection clears with the accepted frame. +TEST(BTHomeMiThermometerReplay, AcceptsLowerCounterAfterTheResyncWindow) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); + ReplayClockAccess::expire_window(h.thermometer); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); +} + +} // namespace esphome::bthome_mithermometer::testing diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 07a341bcdca..014c70b8b9d 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -20,6 +20,27 @@ canbus: then: logger.log: Truth - canbus.send: !lambda return x; + # x forms from the docs + - can_id: 24 + then: + - number.set: + id: canbus_test_number + value: !lambda "return (x.size() > 0) ? (float) x[0] / 255 : 0;" + - lambda: |- + if (x.empty()) + return; + ESP_LOGI("can", "SOC: %d%%", x[0]); + ESP_LOGD("can", "%s", format_hex_pretty(x).c_str()); + uint8_t buf[8]; + memcpy(buf, x.data(), x.size()); + for (uint8_t b : x) + buf[0] ^= b; + std::vector copy = x; + ESP_LOGD("can", "%u %u", buf[0], (unsigned) copy.size()); + - canbus.send: !lambda |- + std::vector data(x.begin(), x.end()); + data.push_back(0x01); + return data; - can_id: 0b00000000000000000000001000000 can_id_mask: 0b11111000000000011111111000000 use_extended_id: true diff --git a/tests/components/canbus/fake_canbus.h b/tests/components/canbus/fake_canbus.h new file mode 100644 index 00000000000..f2c79661143 --- /dev/null +++ b/tests/components/canbus/fake_canbus.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/components/canbus/canbus.h" + +namespace esphome::canbus::testing { + +inline std::vector payload_bytes(const CanFrame &frame) { return frame.payload(); } + +// Hands queued frames to the bus and records the frames it sends +class QueuedCanbus : public Canbus { + public: + void queue(uint32_t can_id, bool extended, std::initializer_list data) { + CanFrame frame{}; + frame.can_id = can_id; + frame.use_extended_id = extended; + frame.can_data_length_code = static_cast(data.size()); + std::copy(data.begin(), data.end(), frame.data); + this->rx_.push_back(frame); + } + void queue(const CanFrame &frame) { this->rx_.push_back(frame); } + + std::vector sent; + Error send_result{ERROR_OK}; + + protected: + bool setup_internal() override { return true; } + Error send_message(CanFrame *frame) override { + this->sent.push_back(*frame); + return this->send_result; + } + Error read_message(CanFrame *frame) override { + if (this->rx_.empty()) + return ERROR_NOMSG; + *frame = this->rx_.front(); + this->rx_.pop_front(); + return ERROR_OK; + } + + std::deque rx_; +}; + +} // namespace esphome::canbus::testing diff --git a/tests/components/canbus/fd_test.cpp b/tests/components/canbus/fd_test.cpp new file mode 100644 index 00000000000..89521a5c8fd --- /dev/null +++ b/tests/components/canbus/fd_test.cpp @@ -0,0 +1,254 @@ +// The rest of the test binary builds canbus without CAN FD, as every in-tree platform does. This file builds a +// second copy with USE_CANBUS_FD in its own namespace, so both frame sizes are tested in one binary. +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/optional.h" + +#define USE_CANBUS_FD +#define canbus canbus_fd // NOLINT(cppcoreguidelines-macro-usage) +#include "esphome/components/canbus/canbus.cpp" // NOLINT(bugprone-suspicious-include) +#include "fake_canbus.h" + +namespace esphome::canbus::testing { + +static_assert(sizeof(CanFrame::data) == CAN_FD_MAX_DATA_LENGTH); +static_assert(can_dlc_to_len(8) == 8); +static_assert(can_dlc_to_len(9) == 12); +static_assert(can_dlc_to_len(15) == 64); +static_assert(can_dlc_to_len(16) == CAN_INVALID_DLC); +static_assert(can_len_to_dlc(8, false) == 8); +static_assert(can_len_to_dlc(9, false) == CAN_INVALID_DLC); +static_assert(can_len_to_dlc(9, true) == 9); +static_assert(can_len_to_dlc(12, false) == 9); +static_assert(can_len_to_dlc(49, true) == 15); +static_assert(can_len_to_dlc(64, false) == 15); +static_assert(can_len_to_dlc(65, true) == CAN_INVALID_DLC); + +// A bus of a platform that supports CAN FD, as register_canbus() sets it up +class FdCanbus : public QueuedCanbus { + public: + FdCanbus() { this->set_supports_fd(true); } +}; + +TEST(CanbusFd, ClassicBraceInitializationStillWorks) { + const CanFrame frame{true, false, 0x1234567, 8, {0, 1, 2, 3, 4, 5, 6, 7}}; + EXPECT_EQ(frame.can_id, 0x1234567u); + EXPECT_FALSE(frame.fd); + EXPECT_FALSE(frame.bit_rate_switch); + EXPECT_EQ(payload_bytes(frame), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); +} + +TEST(CanbusFd, PayloadLengthFollowsTheFrameFormat) { + CanFrame frame{}; + frame.can_data_length_code = 15; + EXPECT_EQ(frame.payload_length(), 8); + frame.fd = true; + EXPECT_EQ(frame.payload_length(), 64); + frame.can_data_length_code = 16; + EXPECT_EQ(frame.payload_length(), 0); +} + +TEST(CanbusFd, FrameValidation) { + struct Case { + const char *name; + bool fd; + bool rtr; + bool brs; + uint8_t dlc; + Error expected; + }; + for (const auto &c : { + Case{"fd 64 bytes", true, false, true, 15, ERROR_OK}, + Case{"fd invalid dlc", true, false, false, 16, ERROR_FAILTX}, + Case{"fd remote request", true, true, false, 8, ERROR_FAILTX}, + Case{"brs without fd", false, false, true, 8, ERROR_FAILTX}, + Case{"classic", false, false, false, 8, ERROR_OK}, + }) { + FdCanbus bus; + CanFrame frame{}; + frame.can_id = 0x123; + frame.fd = c.fd; + frame.remote_transmission_request = c.rtr; + frame.bit_rate_switch = c.brs; + frame.can_data_length_code = c.dlc; + EXPECT_EQ(bus.send_data(frame), c.expected) << c.name; + EXPECT_EQ(bus.sent.size(), c.expected == ERROR_OK ? 1u : 0u) << c.name; + } +} + +TEST(CanbusFd, LengthBasedSend) { + struct Case { + bool fd; + bool pad; + size_t len; + uint8_t expected_dlc; // CAN_INVALID_DLC: not sent + }; + std::vector payload(80, 0xAB); + for (const auto &c : { + Case{true, false, 12, 9}, + Case{true, false, 13, CAN_INVALID_DLC}, + Case{true, true, 13, 10}, + Case{true, true, 64, 15}, + Case{true, true, 65, CAN_INVALID_DLC}, + Case{false, false, 13, 8}, // classic still cuts to 8 bytes + }) { + FdCanbus bus; + const Error result = bus.send_data(0x123, false, false, payload.data(), c.len, c.fd, false, c.pad); + if (c.expected_dlc == CAN_INVALID_DLC) { + EXPECT_EQ(result, ERROR_FAILTX) << c.len; + EXPECT_TRUE(bus.sent.empty()) << c.len; + continue; + } + ASSERT_EQ(bus.sent.size(), 1u) << c.len; + const CanFrame &sent = bus.sent[0]; + EXPECT_EQ(sent.fd, c.fd); + EXPECT_EQ(sent.can_data_length_code, c.expected_dlc) << c.len; + const size_t copied = std::min(c.len, sent.payload_length()); + std::vector expected(sent.payload_length(), 0); + std::fill_n(expected.begin(), copied, 0xAB); + EXPECT_EQ(payload_bytes(sent), expected) << c.len; + } +} + +TEST(CanbusFd, ClassicBusInAnFdBuildRefusesFdFrames) { + FdCanbus fd_bus; + QueuedCanbus classic_bus; + EXPECT_TRUE(fd_bus.supports_fd()); + EXPECT_FALSE(classic_bus.supports_fd()); + static constexpr uint8_t DATA[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + for (QueuedCanbus *bus : {static_cast(&fd_bus), &classic_bus}) { + const Error expected = bus->supports_fd() ? ERROR_OK : ERROR_FAILTX; + EXPECT_EQ(bus->send_data(0x123, false, false, DATA, sizeof(DATA), true), expected); + EXPECT_EQ(bus->send_data(0x123, false, false, DATA, sizeof(DATA)), ERROR_OK); + ASSERT_EQ(bus->sent.size(), bus->supports_fd() ? 2u : 1u); + EXPECT_FALSE(bus->sent.back().fd); + } +} + +TEST(CanbusFd, ClassicDriverAfterFdFrameSeesClearedFlags) { + // Writes only the classic fields, as esp32_can and mcp2515 do + class MixedCanbus : public QueuedCanbus { + protected: + Error read_message(CanFrame *frame) override { + if (this->reads_ == 2) + return ERROR_NOMSG; + frame->can_id = 0x123; + frame->can_data_length_code = 15; + std::fill_n(frame->data, CAN_FRAME_DATA_LENGTH, 0xAB); + if (this->reads_++ == 0) { + frame->fd = true; + frame->bit_rate_switch = true; + frame->error_state_indicator = true; + } + return ERROR_OK; + } + uint8_t reads_{0}; + }; + MixedCanbus bus; + std::vector> received; + bus.add_frame_callback([&](const CanFrame &frame) { + EXPECT_EQ(frame.bit_rate_switch, frame.fd); + EXPECT_EQ(frame.error_state_indicator, frame.fd); + received.emplace_back(frame.fd, frame.payload().size()); + }); + bus.loop(); + EXPECT_EQ(received, (std::vector>{{true, 64}, {false, 8}})); +} + +static bool yes() { return true; } +static bool no() { return false; } + +static CanPayload sixty_four_bytes() { return std::vector(64, 0xAB); } +static CanPayload sixty_five_bytes() { return std::vector(65, 0xAB); } +// Built from a range rather than a vector, so it goes through another constructor +static CanPayload sixty_five_byte_range() { + static constexpr uint8_t DATA[65] = {}; + return CanPayload(std::begin(DATA), std::end(DATA)); +} + +TEST(CanbusFd, ActionRefusesAnFdLambdaPayloadThatDoesNotFit) { + struct Case { + CanPayload (*data)(); + bool expect_sent; + }; + for (const auto &c : + {Case{sixty_four_bytes, true}, Case{sixty_five_bytes, false}, Case{sixty_five_byte_range, false}}) { + FdCanbus bus; + bus.set_can_id(0x100); + bus.set_default_fd(true); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_template(c.data); + action.play(); + ASSERT_EQ(bus.sent.size(), c.expect_sent ? 1u : 0u); + if (c.expect_sent) + EXPECT_EQ(bus.sent[0].payload_length(), 64); + } +} + +TEST(CanbusFd, ActionDefaultsAndOverrides) { + using Flag = bool (*)(); + struct Case { + const char *name; + bool default_fd; + bool default_brs; + bool default_pad; + Flag fd; + Flag brs; + Flag pad; + size_t len; + bool expect_sent; + bool expect_fd; + bool expect_brs; + uint8_t expect_dlc; + }; + static constexpr uint8_t DATA[13] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; + for (const auto &c : { + Case{"classic by default", false, true, false, nullptr, nullptr, nullptr, 8, true, false, false, 8}, + Case{"bus default fd and brs", true, true, false, nullptr, nullptr, nullptr, 12, true, true, true, 9}, + Case{"13 bytes need padding", true, false, false, nullptr, nullptr, nullptr, 13, false, false, false, 0}, + Case{"bus default padding", true, false, true, nullptr, nullptr, nullptr, 13, true, true, false, 10}, + Case{"action padding", true, false, false, nullptr, nullptr, yes, 13, true, true, false, 10}, + Case{"action turns fd off", true, true, false, no, nullptr, nullptr, 8, true, false, false, 8}, + Case{"brs without fd", false, false, false, nullptr, yes, nullptr, 8, false, false, false, 0}, + Case{"action fd without brs", false, true, false, yes, no, nullptr, 8, true, true, false, 8}, + }) { + FdCanbus bus; + bus.set_can_id(0x100); + bus.set_default_fd(c.default_fd); + bus.set_default_bit_rate_switch(c.default_brs); + bus.set_default_pad_data(c.default_pad); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_static(DATA, c.len); + if (c.fd != nullptr) + action.set_fd(c.fd); + if (c.brs != nullptr) + action.set_bit_rate_switch(c.brs); + if (c.pad != nullptr) + action.set_pad_data(c.pad); + action.play(); + ASSERT_EQ(bus.sent.size(), c.expect_sent ? 1u : 0u) << c.name; + if (!c.expect_sent) + continue; + EXPECT_EQ(bus.sent[0].fd, c.expect_fd) << c.name; + EXPECT_EQ(bus.sent[0].bit_rate_switch, c.expect_brs) << c.name; + EXPECT_EQ(bus.sent[0].can_data_length_code, c.expect_dlc) << c.name; + } +} + +} // namespace esphome::canbus::testing + +#undef canbus diff --git a/tests/components/canbus/frame_callback_test.cpp b/tests/components/canbus/frame_callback_test.cpp new file mode 100644 index 00000000000..1f4740cf418 --- /dev/null +++ b/tests/components/canbus/frame_callback_test.cpp @@ -0,0 +1,115 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "fake_canbus.h" + +// Needs AddressSanitizer's malloc hooks +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define CANBUS_TEST_ASAN +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); +#endif + +namespace esphome::canbus::testing { + +TEST(CanbusFrameCallback, PassesEveryFrameAndItsPayload) { + using Received = std::vector>>; + QueuedCanbus bus; + Received received; + bus.add_frame_callback([&](const CanFrame &frame) { received.emplace_back(frame.can_id, frame.payload()); }); + bus.setup(); + bus.queue(0x100, false, {0x01, 0x02}); + bus.queue(0x1ABCDE42, true, {}); + bus.queue(0x7FF, false, {1, 2, 3, 4, 5, 6, 7, 8}); + + bus.loop(); + + EXPECT_EQ(received, (Received{{0x100, {0x01, 0x02}}, {0x1ABCDE42, {}}, {0x7FF, {1, 2, 3, 4, 5, 6, 7, 8}}})); +} + +TEST(CanbusFrameCallback, ReceivingFramesDoesNotAllocate) { +#ifdef CANBUS_TEST_ASAN + static std::atomic allocations{0}; + static const int hooks_installed = __sanitizer_install_malloc_and_free_hooks( + [](const volatile void *, size_t) { allocations.fetch_add(1, std::memory_order_relaxed); }, + [](const volatile void *) {}); + ASSERT_NE(hooks_installed, 0); + QueuedCanbus bus; + size_t bytes_seen = 0; + bus.add_frame_callback([&](const CanFrame &frame) { bytes_seen += frame.payload().size(); }); + bus.setup(); + for (uint8_t i = 0; i < 10; i++) + bus.queue(0x100 + i, false, {i, i, i, i, i, i, i, i}); + + const size_t before = allocations.load(std::memory_order_relaxed); + bus.loop(); + const size_t after = allocations.load(std::memory_order_relaxed); + + EXPECT_EQ(bytes_seen, 80u); + EXPECT_EQ(after - before, 0u); +#else + GTEST_SKIP() << "Allocation counting requires AddressSanitizer"; +#endif +} + +TEST(CanbusFrameCallback, DeprecatedVectorCallbackStillReceivesFrames) { + QueuedCanbus bus; + std::vector received; + uint32_t received_id = 0; + int calls = 0; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + // mutable, as before + bus.add_callback([&, seen = 0](uint32_t can_id, bool, bool, const std::vector &data) mutable { + received_id = can_id; + received = data; + calls = ++seen; + }); +#pragma GCC diagnostic pop + bus.setup(); + bus.queue(0x122, false, {0x01}); + bus.queue(0x123, false, {0xAA, 0xBB}); + + bus.loop(); + + EXPECT_EQ(calls, 2); + EXPECT_EQ(received_id, 0x123u); + EXPECT_EQ(received, (std::vector{0xAA, 0xBB})); +} + +TEST(CanbusFrameCallback, PayloadKeepsVectorForms) { + CanFrame frame{}; + frame.can_data_length_code = 3; + frame.data[0] = 'a'; + frame.data[1] = 'b'; + frame.data[2] = 'c'; + const CanPayload x = frame.payload(); + + EXPECT_EQ(x.size(), 3u); + EXPECT_FALSE(x.empty()); + EXPECT_EQ(x[1], 'b'); + EXPECT_EQ(x.data()[2], 'c'); + EXPECT_EQ(std::string(x.begin(), x.end()), "abc"); + const std::vector copy = x; + EXPECT_EQ(copy, (std::vector{'a', 'b', 'c'})); + auto takes_vector = [](const std::vector &v) { return v.size(); }; + EXPECT_EQ(takes_vector(x), 3u); + + CanPayload braced = {0x00, 0x20, 0x42}; + EXPECT_EQ(braced.size(), 3u); + const CanPayload too_long = std::vector(CAN_MAX_DATA_LENGTH + 2, 7); + EXPECT_EQ(too_long.size(), CAN_MAX_DATA_LENGTH); + frame.can_data_length_code = 15; // legal on the wire + EXPECT_EQ(frame.payload().size(), CAN_MAX_DATA_LENGTH); +} + +} // namespace esphome::canbus::testing diff --git a/tests/components/canbus/send_test.cpp b/tests/components/canbus/send_test.cpp new file mode 100644 index 00000000000..ac1a8014c09 --- /dev/null +++ b/tests/components/canbus/send_test.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include + +#include "fake_canbus.h" + +namespace esphome::canbus::testing { + +// Frames stay as large as on dev, and keep their field order for brace initialization. clang-tidy builds with +// defines.h, which turns CAN FD on. +#ifndef USE_CANBUS_FD +static_assert(sizeof(CanFrame::data) == CAN_MAX_DATA_LENGTH); +static_assert(sizeof(CanFrame) == 24); +static_assert(can_dlc_to_len(9) == 8); +static_assert(can_dlc_to_len(15) == 8); +#endif +static_assert(can_dlc_to_len(0) == 0); +static_assert(can_dlc_to_len(8) == 8); + +TEST(CanbusSend, ClassicFrameBraceInitialization) { + const CanFrame frame{true, true, 0x1234567, 8, {0, 1, 2, 3, 4, 5, 6, 7}}; + EXPECT_TRUE(frame.use_extended_id); + EXPECT_TRUE(frame.remote_transmission_request); + EXPECT_EQ(frame.can_id, 0x1234567u); + EXPECT_EQ(payload_bytes(frame), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); +} + +TEST(CanbusSend, LengthIsClampedToOneFrame) { + const std::vector payload{1, 2, 3, 4, 5, 6, 7, 8, 9}; + for (size_t len : {size_t{0}, size_t{8}, size_t{9}, size_t{256}}) { + QueuedCanbus bus; + const size_t sent_len = std::min(len, CAN_MAX_DATA_LENGTH); + EXPECT_EQ(bus.send_data(0x123, false, false, len == 0 ? nullptr : payload.data(), len), ERROR_OK); + ASSERT_EQ(bus.sent.size(), 1u) << len; + EXPECT_EQ(bus.sent[0].can_data_length_code, sent_len); + EXPECT_EQ(payload_bytes(bus.sent[0]), std::vector(payload.begin(), payload.begin() + sent_len)); + } +} + +TEST(CanbusSend, RemoteRequestDoesNotReadData) { + QueuedCanbus bus; + EXPECT_EQ(bus.send_data(0x123, false, true, nullptr, 8), ERROR_OK); + ASSERT_EQ(bus.sent.size(), 1u); + EXPECT_TRUE(bus.sent[0].remote_transmission_request); + EXPECT_EQ(bus.sent[0].can_data_length_code, 8); + // Zeroed, not left from the stack, since drivers and the verbose log still read them + EXPECT_EQ(payload_bytes(bus.sent[0]), std::vector(8, 0)); +} + +TEST(CanbusSend, FrameIdentifierMustFitItsWidth) { + struct Case { + bool extended; + uint32_t can_id; + Error expected; + }; + for (const auto &c : {Case{false, CAN_STANDARD_ID_MAX, ERROR_OK}, Case{false, CAN_STANDARD_ID_MAX + 1, ERROR_FAILTX}, + Case{true, CAN_EXTENDED_ID_MAX, ERROR_OK}, Case{true, CAN_EXTENDED_ID_MAX + 1, ERROR_FAILTX}}) { + QueuedCanbus bus; + CanFrame frame{}; + frame.use_extended_id = c.extended; + frame.can_id = c.can_id; + EXPECT_EQ(bus.send_data(frame), c.expected) << c.can_id; + EXPECT_EQ(bus.sent.size(), c.expected == ERROR_OK ? 1u : 0u) << c.can_id; + } +} + +TEST(CanbusSend, DriverErrorIsReturned) { + QueuedCanbus bus; + bus.send_result = ERROR_ALLTXBUSY; + EXPECT_EQ(bus.send_data(0x123, false, std::vector{1}), ERROR_ALLTXBUSY); +} + +static CanPayload three_bytes() { return {1, 2, 3}; } +static CanPayload too_long() { return std::vector(20, 0xAB); } + +TEST(CanbusSend, EveryPayloadConstructorRecordsATruncation) { + EXPECT_FALSE(CanPayload({1, 2, 3, 4, 5, 6, 7, 8}).truncated()); + const CanPayload braces{1, 2, 3, 4, 5, 6, 7, 8, 9}; + EXPECT_TRUE(braces.truncated()); + EXPECT_EQ(braces.size(), 8u); + static constexpr uint8_t DATA[9] = {}; + EXPECT_TRUE(CanPayload(std::begin(DATA), std::end(DATA)).truncated()); + EXPECT_TRUE(CanPayload(std::vector(9, 0)).truncated()); + EXPECT_FALSE(CanPayload(std::vector(8, 0)).truncated()); +} + +TEST(CanbusSendAction, UsesTheBusOrItsOwnIdentifier) { + struct Case { + const char *name; + bool own_id; + bool extended; + bool rtr; + CanPayload (*data)(); + uint32_t expected_id; + uint8_t expected_dlc; + }; + for (const auto &c : { + Case{"bus id", false, false, false, three_bytes, 0x100, 3}, + Case{"own extended id", true, true, false, three_bytes, 0x1ABCDE, 3}, + Case{"remote request", true, false, true, three_bytes, 0x1ABCDE, 3}, + Case{"lambda over 8 bytes", false, false, false, too_long, 0x100, 8}, + }) { + QueuedCanbus bus; + bus.set_can_id(0x100); + CanbusSendAction<> action; + action.set_parent(&bus); + if (c.own_id) { + action.set_can_id(0x1ABCDE); + action.set_use_extended_id(c.extended); + } + action.set_remote_transmission_request(c.rtr); + action.set_data_template(c.data); + action.play(); + ASSERT_EQ(bus.sent.size(), 1u) << c.name; + EXPECT_EQ(bus.sent[0].can_id, c.expected_id) << c.name; + EXPECT_EQ(bus.sent[0].use_extended_id, c.extended) << c.name; + EXPECT_EQ(bus.sent[0].remote_transmission_request, c.rtr) << c.name; + EXPECT_EQ(bus.sent[0].can_data_length_code, c.expected_dlc) << c.name; + } +} + +TEST(CanbusSendAction, StaticPayload) { + static constexpr uint8_t DATA[] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + QueuedCanbus bus; + bus.set_can_id(0x100); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_static(DATA, sizeof(DATA)); + action.play(); + ASSERT_EQ(bus.sent.size(), 1u); + EXPECT_EQ(payload_bytes(bus.sent[0]), (std::vector{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88})); +} + +} // namespace esphome::canbus::testing diff --git a/tests/components/core/hub_publisher_test.cpp b/tests/components/core/hub_publisher_test.cpp new file mode 100644 index 00000000000..7c97ffd00b9 --- /dev/null +++ b/tests/components/core/hub_publisher_test.cpp @@ -0,0 +1,50 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +class PublishingHub { + public: + bool publish(int value) { + if (this->value_publisher_ == nullptr) + return false; + this->value_publisher_(value); + return true; + } + + HUB_PUBLISHER(value, int) + + bool claim(uint32_t id) { return this->claim_publisher_ != nullptr && this->claim_publisher_(id); } + + HUB_PUBLISHER_RETURNING(bool, claim, uint32_t) +}; + +static int published = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static void record(int value) { published = value; } + +TEST(HubPublisher, StartsUnsetAndCallsTheFunctionOnceSet) { + PublishingHub hub; + published = 0; + EXPECT_FALSE(hub.publish(5)); + hub.set_value_publisher(record); + EXPECT_TRUE(hub.publish(7)); + EXPECT_EQ(published, 7); +} + +// The shape cg.HubFunction emits for a bool function (it names these esphome__arg_id and esphome__found) +static bool claim_seven(uint32_t id) { + bool found = false; + (found |= id == 7); + return found; +} + +TEST(HubPublisher, BoolPublisherReturnsWhetherAnyItemClaimed) { + PublishingHub hub; + EXPECT_FALSE(hub.claim(7)); + hub.set_claim_publisher(claim_seven); + EXPECT_TRUE(hub.claim(7)); + EXPECT_FALSE(hub.claim(8)); +} + +} // namespace esphome::testing diff --git a/tests/components/core/test_static_ring_buffer.cpp b/tests/components/core/test_static_ring_buffer.cpp new file mode 100644 index 00000000000..92fb27190a8 --- /dev/null +++ b/tests/components/core/test_static_ring_buffer.cpp @@ -0,0 +1,50 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +template static std::vector drain(StaticRingBuffer &buf) { + std::vector out; + while (!buf.empty()) { + out.push_back(buf.front()); + buf.pop(); + } + return out; +} + +TEST(StaticRingBufferPushFront, PoppedFirst) { + StaticRingBuffer buf; + buf.push(1); + buf.push(2); + EXPECT_TRUE(buf.push_front(0)); + EXPECT_EQ(buf.size(), 3); + EXPECT_EQ(drain(buf), (std::vector{0, 1, 2})); +} + +TEST(StaticRingBufferPushFront, WrapsBelowZeroAndRejectsWhenFull) { + StaticRingBuffer buf; + EXPECT_TRUE(buf.push_front(3)); + EXPECT_TRUE(buf.push_front(2)); + EXPECT_TRUE(buf.push_front(1)); + EXPECT_TRUE(buf.push(4)); + EXPECT_FALSE(buf.push_front(0)); + EXPECT_EQ(drain(buf), (std::vector{1, 2, 3, 4})); +} + +TEST(StaticRingBufferPushFront, AfterTailWraps) { + StaticRingBuffer buf; + buf.push(1); + buf.push(2); + buf.push(3); + buf.pop(); + buf.pop(); + buf.push(4); + buf.push(5); // tail wrapped to index 1, head at 2 + EXPECT_TRUE(buf.push_front(0)); + EXPECT_EQ(drain(buf), (std::vector{0, 3, 4, 5})); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_templatable_bytes.cpp b/tests/components/core/test_templatable_bytes.cpp index b75b8c1b556..eae17d25b4d 100644 --- a/tests/components/core/test_templatable_bytes.cpp +++ b/tests/components/core/test_templatable_bytes.cpp @@ -1,6 +1,7 @@ #include #include #include "esphome/core/automation.h" +#include "esphome/core/helpers.h" namespace esphome::testing { @@ -40,4 +41,38 @@ TEST(TemplatableBytesTest, VisitLambdaWithArgument) { EXPECT_EQ(seen, (std::vector(4, 7))); } +using Fixed = StaticVector; + +static Fixed pair(int first) { return {static_cast(first), 9}; } + +TEST(TemplatableBytesTest, FixedBufferLambda) { + TemplatableBuffer bytes; + bytes.set_template(pair); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }, 4); + EXPECT_EQ(seen, (std::vector{4, 9})); +} + +TEST(TemplatableBytesTest, FixedBufferKeepsWhatFits) { + TemplatableBuffer bytes; + bytes.set_static(PAYLOAD, sizeof(PAYLOAD)); + const Fixed value = bytes.value(); + EXPECT_EQ(std::vector(value.begin(), value.end()), (std::vector{1, 2})); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }); + EXPECT_EQ(seen, (std::vector{1, 2})); +} + +template class FixedHolder { + TEMPLATABLE_BUFFER(data, StaticVector) + + size_t size(const Ts &...x) const { return this->data_.value(x...).size(); } +}; + +TEST(TemplatableBytesTest, BufferMacroTakesTypeWithComma) { + FixedHolder holder; + holder.set_data_template(pair); + EXPECT_EQ(holder.size(1), 2u); +} + } // namespace esphome::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index d60201b6a4f..248660aecab 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -515,6 +515,43 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + # Good Display GDEY0583T81 5.83" mono e-paper (648x480, UC8179) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey0583t81-5.83 + full_update_every: 5 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + + # Seeed 0583BN-T81-D2, the GDEY0583T81 panel under its label name + - platform: epaper_spi + spi_id: spi_bus + model: 0583BN-T81-D2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + # Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179) # Pins overridden to avoid conflicts with the E1002 defaults above - platform: epaper_spi diff --git a/tests/components/ezo/test_ezo_commands.cpp b/tests/components/ezo/test_ezo_commands.cpp index 18c32a802d8..98914fbe611 100644 --- a/tests/components/ezo/test_ezo_commands.cpp +++ b/tests/components/ezo/test_ezo_commands.cpp @@ -96,6 +96,31 @@ TEST_F(EzoCommandTest, WireTextOfEachCommand) { "Name,?", "R"})); } +TEST_F(EzoCommandTest, RepliesReachTheirCallbacks) { + std::vector got; + this->ezo_.add_device_infomation_callback([&got](const std::string &x) { got.push_back("info " + x); }); + this->ezo_.add_slope_callback([&got](const std::string &x) { got.push_back("slope " + x); }); + this->ezo_.add_calibration_callback([&got](const std::string &x) { got.push_back("cal " + x); }); + this->ezo_.add_t_callback([&got](const std::string &x) { got.push_back("t " + x); }); + this->ezo_.add_led_state_callback([&got](bool on) { got.emplace_back(on ? "led on" : "led off"); }); + this->ezo_.add_custom_callback([&got](const std::string &x) { got.push_back("custom " + x); }); + this->bus_.replies = { + {"i", "?I,pH,2.16"}, {"Slope,?", "?Slope,99.7,100.3,-0.89"}, {"Cal,?", "?Cal,2"}, {"T,?", "?T,19.5"}, + {"L,?", "?L,1"}, {"Status", "?Status,P,5.038"}, {"R", "7.25,1.5"}}; + this->ezo_.get_device_information(); + this->ezo_.get_slope(); + this->ezo_.get_calibration(); + this->ezo_.get_t(); + this->ezo_.get_led_state(); + this->ezo_.send_custom("Status"); + this->ezo_.get_state(); + this->run_until_written_(7); + EXPECT_EQ(got, (std::vector{"info pH,2.16", "slope 99.7,100.3,-0.89", "cal 2", "t 19.5", "led on", + "custom ?Status,P,5.038"})); + // A read publishes the first of several values. + EXPECT_FLOAT_EQ(this->ezo_.state, 7.25f); +} + TEST_F(EzoCommandTest, AddressChangeAppliesToLaterCommands) { this->ezo_.set_address(100); this->ezo_.get_led_state(); diff --git a/tests/components/ezo/test_ezo_queue.cpp b/tests/components/ezo/test_ezo_queue.cpp new file mode 100644 index 00000000000..806a3cf70b7 --- /dev/null +++ b/tests/components/ezo/test_ezo_queue.cpp @@ -0,0 +1,248 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/ezo/ezo.h" +#include "esphome/core/application.h" + +namespace esphome::ezo::testing { + +static constexpr uint8_t ADDRESS = 99; + +// Records each command written and answers every read with a success code and `reply`. +class EzoBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + this->addresses.push_back(address); + if (size != 0) + this->writes.emplace_back(reinterpret_cast(data), size); + if (out_size != 0) { + memset(out, 0, out_size); + out[0] = 1; + memcpy(out + 1, this->reply.c_str(), std::min(this->reply.size(), out_size - 1)); + } + return i2c::ERROR_OK; + } + + std::vector writes; + std::vector addresses; + std::string reply{"7.00"}; +}; + +class EzoQueueTest : public ::testing::Test { + protected: + void SetUp() override { + this->ezo_.set_i2c_bus(&this->bus_); + this->ezo_.set_i2c_address(ADDRESS); + } + + // One loop pass, one second after the previous one, so every reply delay has passed. + void step_() { + this->now_ += 1000; + LoopBlockingGuard guard{nullptr, nullptr, this->now_}; + this->ezo_.loop(); + } + + void run_all_() { + for (int i = 0; i < 64; i++) + this->step_(); + } + + EzoBus bus_; + EZOSensor ezo_; + uint32_t now_{0}; +}; + +TEST_F(EzoQueueTest, ReadRunsNextAndOnlyOnce) { + this->ezo_.get_led_state(); + this->ezo_.get_slope(); + this->step_(); // "L,?" is in flight + this->ezo_.update(); + this->ezo_.update(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"L,?", "R", "Slope,?"})); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.0f); +} + +TEST_F(EzoQueueTest, ReadsDoNotStarveOtherCommands) { + this->ezo_.get_led_state(); + this->ezo_.get_slope(); + this->ezo_.update(); + this->step_(); // the read goes first since nothing was in flight + this->ezo_.update(); // asked again while the first read is in flight + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"R", "L,?", "R", "Slope,?"})); +} + +TEST_F(EzoQueueTest, WireTextOfEachCommand) { + this->ezo_.get_device_information(); + this->ezo_.set_sleep(); + this->ezo_.get_slope(); + this->ezo_.get_t(); + this->ezo_.set_t(21.5f); + this->ezo_.get_calibration(); + this->ezo_.clear_calibration(); + this->ezo_.set_calibration_point_low(4.0f); + this->run_all_(); + this->ezo_.set_tempcomp_value(-5.256f); + this->ezo_.set_calibration_point_mid(7.0f); + this->ezo_.set_calibration_point_high(10.0f); + this->ezo_.set_calibration_generic(1413.0f); + this->ezo_.get_led_state(); + this->ezo_.set_led_state(false); + this->ezo_.set_led_state(true); + this->ezo_.send_custom("Name,?"); + this->run_all_(); + this->ezo_.get_state(); + this->ezo_.set_address(100); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, + (std::vector{"i", "Sleep", "Slope,?", "T,?", "T,21.50", "Cal,?", "Cal,clear", "Cal,LOW,4.00", + "T,-5.26", "Cal,MID,7.00", "Cal,HIGH,10.00", "Cal,1413.00", "L,?", "L,0", "L,1", + "Name,?", "R", "I2C,100"})); +} + +// An explicit read waits its turn, so it sees the compensation queued before it. +TEST_F(EzoQueueTest, ExplicitReadRunsInOrder) { + this->ezo_.set_t(25.0f); + this->ezo_.get_state(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"T,25.00", "R"})); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.0f); +} + +// The finished command frees its slot before its callbacks run. +TEST_F(EzoQueueTest, CallbackCanQueueIntoFullQueue) { + bool queued = false; + this->ezo_.add_led_state_callback([this, &queued](bool) { + if (!queued) { + queued = true; + this->ezo_.get_slope(); + } + }); + this->bus_.reply = "?L,1"; + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.get_led_state(); + this->run_all_(); + ASSERT_EQ(this->bus_.writes.size(), EZO_COMMAND_QUEUE_SIZE + 1u); + EXPECT_EQ(this->bus_.writes.back(), "Slope,?"); +} + +TEST_F(EzoQueueTest, CustomCallbackCanSendCustom) { + bool sent = false; + this->ezo_.add_custom_callback([this, &sent](const std::string &) { + if (!sent) { + sent = true; + this->ezo_.send_custom("Find"); + } + }); + this->ezo_.send_custom("Status"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"Status", "Find"})); +} + +TEST_F(EzoQueueTest, LongestDocumentedCommandFits) { + this->ezo_.send_custom("Import, 59 6F 75 20 61 72"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"Import, 59 6F 75 20 61 72"})); +} + +TEST_F(EzoQueueTest, FullQueueDropsNewCommands) { + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.set_calibration_generic(static_cast(i)); + this->ezo_.get_led_state(); // one more than fits + this->ezo_.update(); // reads do not take a slot + this->run_all_(); + std::vector expected{"R"}; + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) { + char text[16]; + snprintf(text, sizeof(text), "Cal,%u.00", i); + expected.emplace_back(text); + } + EXPECT_EQ(this->bus_.writes, expected); +} + +// Custom commands queued back to back all go out, in order. +TEST_F(EzoQueueTest, CustomCommandsRunInOrder) { + this->ezo_.send_custom("O,EC,1"); + this->ezo_.send_custom("O,TDS,0"); + this->ezo_.get_led_state(); + this->ezo_.send_custom("O,S,0"); + this->ezo_.send_custom("O,SG,1"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"O,EC,1", "O,TDS,0", "L,?", "O,S,0", "O,SG,1"})); +} + +// The custom texts share EZO_CUSTOM_TEXT_SIZE bytes; one that does not fit is dropped, and the space comes back +// as the commands finish, including texts that wrap around the end of the buffer. +TEST_F(EzoQueueTest, CustomTextSpaceIsShared) { + const std::string a(EZO_MAX_COMMAND_LENGTH, 'a'); + const std::string b(EZO_MAX_COMMAND_LENGTH, 'b'); + const std::string c(EZO_CUSTOM_TEXT_SIZE - 2 * EZO_MAX_COMMAND_LENGTH + 1, 'c'); + this->ezo_.send_custom(a); + this->ezo_.send_custom(b); + this->ezo_.send_custom(c); // one byte too many + this->ezo_.send_custom("x"); + this->run_all_(); + this->ezo_.send_custom(c); // wraps around the end + this->ezo_.send_custom(a); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{a, b, "x", c, a})); +} + +TEST_F(EzoQueueTest, TemperatureUpdatesMergeUntilSent) { + this->ezo_.set_t(20.0f); + this->ezo_.get_led_state(); + this->ezo_.set_tempcomp_value(25.0f); // replaces the 20 still waiting + this->step_(); // "T,25.00" is in flight + this->ezo_.set_t(30.0f); // queued after it + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"T,25.00", "L,?", "T,30.00"})); +} + +TEST_F(EzoQueueTest, CustomCommandDroppedByFullQueueFreesTheText) { + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.get_led_state(); + this->ezo_.send_custom("Name,?"); + this->run_all_(); + this->ezo_.send_custom("Status"); + this->run_all_(); + ASSERT_EQ(this->bus_.writes.size(), EZO_COMMAND_QUEUE_SIZE + 1u); + EXPECT_EQ(this->bus_.writes.back(), "Status"); +} + +TEST_F(EzoQueueTest, OversizeCustomCommandIsRejected) { + std::vector replies; + this->ezo_.add_custom_callback([&replies](const std::string &x) { replies.push_back(x); }); + this->bus_.reply = "ok"; + this->ezo_.send_custom(std::string(EZO_MAX_COMMAND_LENGTH + 1, 'A')); + this->ezo_.send_custom(std::string(EZO_MAX_COMMAND_LENGTH, 'B')); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{std::string(EZO_MAX_COMMAND_LENGTH, 'B')})); + EXPECT_EQ(replies, (std::vector{"ok"})); +} + +TEST_F(EzoQueueTest, ReadKeepsOnlyTheFirstValue) { + this->bus_.reply = "7.25,1.5"; + this->ezo_.update(); + this->run_all_(); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.25f); +} + +TEST_F(EzoQueueTest, AddressChangesApplyInOrder) { + this->ezo_.set_address(100); + this->ezo_.get_led_state(); + this->ezo_.set_address(101); + this->ezo_.get_led_state(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"I2C,100", "L,?", "I2C,101", "L,?"})); + // Each change needs no reply and goes to the address set before it; later commands go to the new one. + EXPECT_EQ(this->bus_.addresses, (std::vector{ADDRESS, 100, 100, 100, 101, 101})); +} + +} // namespace esphome::ezo::testing diff --git a/tests/components/ezo/test_ezo_reply.cpp b/tests/components/ezo/test_ezo_reply.cpp new file mode 100644 index 00000000000..61b3ade3cf9 --- /dev/null +++ b/tests/components/ezo/test_ezo_reply.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/ezo/ezo.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::ezo::testing { + +// Answers every read with a success code and `reply`, with no terminator when the reply fills the read. +class EzoReplyBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (out_size != 0) { + memset(out, 0, out_size); + out[0] = 1; + memcpy(out + 1, this->reply.data(), std::min(this->reply.size(), out_size - 1)); + this->read_sizes.push_back(out_size); + } + return i2c::ERROR_OK; + } + + std::string reply; + std::vector read_sizes; +}; + +static std::vector run_custom(const std::string &reply) { + EzoReplyBus bus; + bus.reply = reply; + EZOSensor ezo; + ezo.set_i2c_bus(&bus); + ezo.set_i2c_address(99); + std::vector replies; + ezo.add_custom_callback([&replies](const std::string &x) { replies.push_back(x); }); + ezo.send_custom("Status"); + // The reply is read once its delay has passed on the real clock. + const uint32_t start = millis(); + while (replies.empty() && millis() - start < 2000) { + LoopBlockingGuard guard{nullptr, nullptr, millis()}; + ezo.loop(); + delay(10); + } + return replies; +} + +// EZO-RGB's largest reply is 52 characters; it arrives whole even with no terminator in the read. +TEST(EzoReplyTest, LargestReplyIsDeliveredWhole) { + const std::string reply(52, 'x'); + EXPECT_EQ(run_custom(reply), (std::vector{reply})); +} + +TEST(EzoReplyTest, ShortReplyIsUnchanged) { + EXPECT_EQ(run_custom("?Status,P,5.038"), (std::vector{"?Status,P,5.038"})); +} + +} // namespace esphome::ezo::testing diff --git a/tests/components/gpio_expander/test_cached_gpio_component.cpp b/tests/components/gpio_expander/test_cached_gpio_component.cpp index 5f357e0730e..ccf74e3d658 100644 --- a/tests/components/gpio_expander/test_cached_gpio_component.cpp +++ b/tests/components/gpio_expander/test_cached_gpio_component.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio_expander::testing { -class FakeComponent : public CachedGpioExpanderComponent { +class FakeComponent : public CachedGpioExpanderComponent { public: void setup() override { this->setup_interrupt_pin_(); } void pin_mode(uint8_t pin, gpio::Flags flags) { @@ -12,14 +12,15 @@ class FakeComponent : public CachedGpioExpanderComponent { this->enable_polling_loop_(); } + uint8_t chip[2]{}; unsigned reads{0}; protected: bool digital_read_hw(uint8_t pin) override { this->reads++; + this->input_[pin / 8] = this->chip[pin / 8]; return true; } - bool digital_read_cache(uint8_t pin) override { return false; } void digital_write_hw(uint8_t pin, bool value) override {} }; @@ -57,13 +58,20 @@ TEST_F(CachedGpioComponentTest, WithoutAnInterruptPinEveryReadHitsTheChip) { TEST_F(CachedGpioComponentTest, InterruptPinKeepsTheCacheUntilTheLoopRuns) { this->component_.set_interrupt_pin(&this->int_pin_); this->component_.setup(); - this->component_.digital_read(0); - this->component_.digital_read(0); - EXPECT_EQ(this->component_.reads, 1u); + this->component_.chip[0] = 0x01; + this->component_.chip[1] = 0x80; + EXPECT_TRUE(this->component_.digital_read(0)); + EXPECT_TRUE(this->component_.digital_read(15)); + this->component_.chip[0] = 0x00; + this->component_.chip[1] = 0x00; + EXPECT_TRUE(this->component_.digital_read(0)); + EXPECT_TRUE(this->component_.digital_read(15)); + EXPECT_EQ(this->component_.reads, 2u); this->component_.loop(); - this->component_.digital_read(0); - EXPECT_EQ(this->component_.reads, 2u); + EXPECT_FALSE(this->component_.digital_read(0)); + EXPECT_FALSE(this->component_.digital_read(15)); + EXPECT_EQ(this->component_.reads, 4u); } TEST_F(CachedGpioComponentTest, LoopStaysOnUntilIntIsReleased) { diff --git a/tests/components/gpio_expander/test_gpio_expander_inputs.cpp b/tests/components/gpio_expander/test_gpio_expander_inputs.cpp new file mode 100644 index 00000000000..e77633dfb4a --- /dev/null +++ b/tests/components/gpio_expander/test_gpio_expander_inputs.cpp @@ -0,0 +1,93 @@ +#include + +#include "esphome/components/gpio_expander/cached_gpio.h" + +namespace esphome::gpio_expander::testing { + +// Reads one bank of the fake chip per digital_read_hw() call. +template class FakeInputsExpander : public GpioExpanderInputs> { + using Base = GpioExpanderInputs>; + + public: + using Base::BANKS; + + void keep_cache() { this->set_invalidate_on_read_(false); } + void reset() { this->reset_pin_cache_(); } + + T chip[BANKS]{}; + unsigned reads[BANKS]{}; + bool fail{false}; + + protected: + bool digital_read_hw(uint8_t pin) override { + const uint8_t bank = pin / this->BANK_SIZE; + this->reads[bank]++; + if (this->fail) + return false; + this->input_[bank] = this->chip[bank]; + return true; + } + void digital_write_hw(uint8_t pin, bool value) override {} +}; + +// Every pin returns its bit of the packed chip state, with one read per bank. +template void expect_every_pin(uint32_t packed) { + FakeInputsExpander expander; + constexpr uint8_t bank_size = sizeof(T) * 8; + for (size_t bank = 0; bank < expander.BANKS; bank++) + expander.chip[bank] = packed >> (bank * bank_size); + for (uint16_t pin = 0; pin < N; pin++) + EXPECT_EQ(expander.digital_read(pin), bool((packed >> pin) & 1)) << "pin " << unsigned(pin); + for (size_t bank = 0; bank < expander.BANKS; bank++) + EXPECT_EQ(expander.reads[bank], 1u) << "bank " << bank; +} + +TEST(GpioExpanderInputs, ReturnsEveryPin) { + expect_every_pin(0x3CA5); + expect_every_pin(0x8001); // one bank read in one transfer + expect_every_pin(0x40028001); +} + +TEST(GpioExpanderInputs, ReadingOneBankKeepsTheOther) { + FakeInputsExpander expander; + expander.chip[0] = 0xFF; + expander.chip[1] = 0x0F; + EXPECT_TRUE(expander.digital_read(0)); + EXPECT_TRUE(expander.digital_read(8)); + + // Pin 8 was invalidated by its read, so only bank 1 is read again. + expander.chip[0] = 0x00; + expander.chip[1] = 0xF0; + EXPECT_FALSE(expander.digital_read(8)); + EXPECT_TRUE(expander.digital_read(12)); + EXPECT_TRUE(expander.digital_read(1)); + EXPECT_EQ(expander.reads[0], 1u); + EXPECT_EQ(expander.reads[1], 2u); +} + +TEST(GpioExpanderInputs, FailedReadReturnsLowAndKeepsOtherPinsCached) { + FakeInputsExpander expander; + expander.chip[0] = 0x03; + EXPECT_TRUE(expander.digital_read(0)); + + expander.fail = true; + expander.chip[0] = 0x00; + EXPECT_FALSE(expander.digital_read(0)); + EXPECT_TRUE(expander.digital_read(1)); +} + +TEST(GpioExpanderInputs, KeepCacheUntilReset) { + FakeInputsExpander expander; + expander.keep_cache(); + expander.chip[0] = 0x01; + EXPECT_TRUE(expander.digital_read(0)); + expander.chip[0] = 0x00; + EXPECT_TRUE(expander.digital_read(0)); + EXPECT_EQ(expander.reads[0], 1u); + + expander.reset(); + EXPECT_FALSE(expander.digital_read(0)); + EXPECT_EQ(expander.reads[0], 2u); +} + +} // namespace esphome::gpio_expander::testing diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index aae80b33573..2cc8c89d942 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -31,6 +31,7 @@ mqtt: payload: hi qos: 2 retain: true + max_payload_size: 4096 keepalive: 60s reboot_timeout: 60s on_message: diff --git a/tests/components/nfc/test_ndef_message.cpp b/tests/components/nfc/test_ndef_message.cpp new file mode 100644 index 00000000000..f18cc16a2fb --- /dev/null +++ b/tests/components/nfc/test_ndef_message.cpp @@ -0,0 +1,50 @@ +#include + +#include +#include + +#include "esphome/components/nfc/ndef_message.h" + +namespace esphome::nfc::testing { + +static size_t record_count(const std::vector &data) { return NdefMessage(data).get_records().size(); } + +// Header: MB|ME|SR|TNF=well-known, type length 1, payload length 4, type "T", payload (status, "en", "x") +TEST(NdefMessage, ParsesShortTextRecord) { + NdefMessage message(std::vector{0xD1, 0x01, 0x04, 'T', 0x02, 'e', 'n', 'x'}); + ASSERT_EQ(message.get_records().size(), 1u); + EXPECT_EQ(message.get_records()[0]->get_type(), "T"); + EXPECT_EQ(message.get_records()[0]->get_payload(), "x"); +} + +TEST(NdefMessage, ParsesRecordWithId) { + NdefMessage message(std::vector{0xD9, 0x01, 0x04, 0x02, 'T', 'i', 'd', 0x02, 'e', 'n', 'x'}); + ASSERT_EQ(message.get_records().size(), 1u); + EXPECT_EQ(message.get_records()[0]->get_type(), "T"); + EXPECT_EQ(message.get_records()[0]->get_id(), "id"); + EXPECT_EQ(message.get_records()[0]->get_payload(), "x"); +} + +TEST(NdefMessage, RejectsTypeLengthPastEnd) { + EXPECT_EQ(record_count({0xD1, 0xFF, 0x00, 'T'}), 0u); + EXPECT_EQ(record_count({0xD1, 0x02, 0x00, 'T'}), 0u); +} + +TEST(NdefMessage, RejectsIdLengthPastEnd) { + EXPECT_EQ(record_count({0xD9, 0x01, 0x00, 0xFF, 'T'}), 0u); + EXPECT_EQ(record_count({0xD9, 0x01, 0x00, 0x02, 'T', 'i'}), 0u); +} + +TEST(NdefMessage, RejectsPayloadLengthPastEnd) { EXPECT_EQ(record_count({0xD1, 0x01, 0x05, 'T', 'x'}), 0u); } + +// Long record with a 4-byte payload length that would wrap a 32-bit pointer +TEST(NdefMessage, RejectsHugePayloadLength) { + EXPECT_EQ(record_count({0xC1, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 'T', 'x'}), 0u); +} + +TEST(NdefMessage, RejectsTruncatedHeader) { + EXPECT_EQ(record_count({0xD1}), 0u); + EXPECT_EQ(record_count({0xD1, 0x01}), 0u); +} + +} // namespace esphome::nfc::testing diff --git a/tests/components/remote_base/raw_trigger_test.cpp b/tests/components/remote_base/raw_trigger_test.cpp new file mode 100644 index 00000000000..20d3e97f024 --- /dev/null +++ b/tests/components/remote_base/raw_trigger_test.cpp @@ -0,0 +1,34 @@ +#include +#include "esphome/components/remote_base/raw_protocol.h" +#include "esphome/core/base_automation.h" + +namespace esphome::remote_base::testing { + +namespace { + +// Records x from every firing of an on_raw trigger. +struct Recorder { + explicit Recorder(RawTrigger *trigger) : automation(trigger) { this->automation.add_action(&this->action); } + std::vector frames; + LambdaAction action{[this](const RawTimings &x) { this->frames.push_back(x); }}; + Automation automation; +}; + +} // namespace + +TEST(RawTriggerTest, CopiesFramesFromAReceiverBuffer) { + RawTrigger trigger; + Recorder recorder(&trigger); + RemoteReceiverListener *listener = &trigger; + + static const int32_t LONG_FRAME[] = {100, -200, 300, -400}; + static const int32_t SHORT_FRAME[] = {500, -600}; + listener->on_receive(RemoteReceiveData(RawTimingsView(LONG_FRAME), 25, TOLERANCE_MODE_PERCENTAGE)); + listener->on_receive(RemoteReceiveData(RawTimingsView(SHORT_FRAME), 25, TOLERANCE_MODE_PERCENTAGE)); + + ASSERT_EQ(recorder.frames.size(), 2u); + EXPECT_EQ(recorder.frames[0], (RawTimings{100, -200, 300, -400})); + EXPECT_EQ(recorder.frames[1], (RawTimings{500, -600})); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/receive_data_test.cpp b/tests/components/remote_base/receive_data_test.cpp new file mode 100644 index 00000000000..d01d6022bb7 --- /dev/null +++ b/tests/components/remote_base/receive_data_test.cpp @@ -0,0 +1,23 @@ +#include +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_base::testing { + +TEST(RemoteReceiveDataTest, KeepsTheVectorItWasGiven) { + const RawTimings timings{100, -200, 300}; + RemoteReceiveData data(timings, 25, TOLERANCE_MODE_PERCENTAGE); + EXPECT_EQ(data.get_raw_vector(), &timings); + EXPECT_EQ(data.get_raw_data().data(), timings.data()); + EXPECT_EQ(data.size(), 3); +} + +TEST(RemoteReceiveDataTest, ViewsABufferWithoutAVector) { + static const int32_t BUFFER[] = {100, -200, 300, -400}; + RemoteReceiveData data(RawTimingsView(BUFFER, 3), 25, TOLERANCE_MODE_PERCENTAGE); + EXPECT_EQ(data.get_raw_vector(), nullptr); + EXPECT_EQ(data.get_raw_data().data(), BUFFER); + EXPECT_EQ(data.size(), 3); + EXPECT_TRUE(data.expect_mark(100)); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index accfdf1b2cf..7720821aed3 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -129,10 +129,16 @@ on_pronto: format: "on_pronto: %s" args: ["x.data.c_str()"] on_raw: - then: - - logger.log: - format: "on_raw: %ld" - args: ["(long) x.front()"] + - then: + - logger.log: + format: "on_raw: %ld" + args: ["(long) x.front()"] + # A deferring automation gets its own copy of x + - then: + - delay: 10ms + - logger.log: + format: "on_raw later: %u" + args: ["(unsigned) x.size()"] on_rc5: then: - logger.log: diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 1cb5fe0737b..60b810de9b2 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -16,3 +16,30 @@ light: bit0_low: 1.2us bit1_high: .69us bit1_low: .4us + # Same chipset as the first strip, on the other PIO + - platform: rp2040_pio_led_strip + id: led_strip_ws2812_pio1 + pin: 6 + num_leds: 60 + pio: 1 + channel_colors: GRB + chipset: WS2812 + # Custom timings that differ from the first custom strip + - platform: rp2040_pio_led_strip + id: led_strip_custom_timings_2 + pin: 7 + num_leds: 60 + pio: 0 + channel_colors: GRB + bit0_high: .4us + bit0_low: .85us + bit1_high: .8us + bit1_low: .45us + # Same chipset and PIO as the first strip, so it reuses the loaded program + - platform: rp2040_pio_led_strip + id: led_strip_ws2812_pio0_second + pin: 8 + num_leds: 60 + pio: 0 + channel_colors: GRB + chipset: WS2812 diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp index c76757bbee6..7559eb886e5 100644 --- a/tests/components/serial_proxy/test_usb_device_descriptor.cpp +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -37,6 +37,18 @@ TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { EXPECT_EQ(encode(msg), expected); } +TEST(UsbDeviceDescriptor, EncodesTheInterfaceDescription) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + msg.interface_description = StringRef("Zigbee"); + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01, + 0x3A, 0x06, 'Z', 'i', 'g', 'b', 'e', 'e'}; + EXPECT_EQ(encode(msg), expected); +} + TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { // The new fields are additive with zero defaults, so an old client sees the message it always did UsbDeviceDescriptor msg; diff --git a/tests/components/store_yaml/common.yaml b/tests/components/store_yaml/common.yaml new file mode 100644 index 00000000000..fc6ad1fe752 --- /dev/null +++ b/tests/components/store_yaml/common.yaml @@ -0,0 +1,4 @@ +api: + +store_yaml: + allow_unencrypted: true diff --git a/tests/components/store_yaml/test.bk72xx-ard.yaml b/tests/components/store_yaml/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.bk72xx-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.esp32-idf.yaml b/tests/components/store_yaml/test.esp32-idf.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.esp8266-ard.yaml b/tests/components/store_yaml/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.host.yaml b/tests/components/store_yaml/test.host.yaml new file mode 100644 index 00000000000..6c822b28eef --- /dev/null +++ b/tests/components/store_yaml/test.host.yaml @@ -0,0 +1,4 @@ +network: + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.ln882x-ard.yaml b/tests/components/store_yaml/test.ln882x-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.ln882x-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.rp2040-ard.yaml b/tests/components/store_yaml/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.rtl87xx-ard.yaml b/tests/components/store_yaml/test.rtl87xx-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.rtl87xx-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/validate.esp32-idf.yaml b/tests/components/store_yaml/validate.esp32-idf.yaml new file mode 100644 index 00000000000..cc09a556d4f --- /dev/null +++ b/tests/components/store_yaml/validate.esp32-idf.yaml @@ -0,0 +1,9 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + +store_yaml: diff --git a/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp index d5705ee0df2..7b53b551924 100644 --- a/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp +++ b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp @@ -10,7 +10,6 @@ namespace esphome::thermopro_ble::testing { class ThermoProBLETest : public ::testing::Test { protected: void SetUp() override { - this->hub_.set_address(device_from(MAC, nullptr, {}).address_uint64()); this->hub_.set_temperature_sensor(&this->temperature_); this->hub_.set_external_temperature_sensor(&this->external_temperature_); this->hub_.set_humidity_sensor(&this->humidity_); @@ -27,7 +26,7 @@ class ThermoProBLETest : public ::testing::Test { !this->humidity_.has_state() && !this->battery_level_.has_state(); } - ThermoProBLE hub_; + ThermoProBLE hub_{device_from(MAC, nullptr, {}).address_uint64()}; sensor::Sensor temperature_; sensor::Sensor external_temperature_; sensor::Sensor humidity_; diff --git a/tests/components/usb_host/test.esp32-p4-idf.yaml b/tests/components/usb_host/test.esp32-p4-idf.yaml index a71140feca3..c0acbe288f3 100644 --- a/tests/components/usb_host/test.esp32-p4-idf.yaml +++ b/tests/components/usb_host/test.esp32-p4-idf.yaml @@ -9,3 +9,4 @@ usb_host: pid: 0xABCD manufacturer: Example Corp product: Example Widget + interface_description: Example Port diff --git a/tests/components/usb_host/test.esp32-s3-idf.yaml b/tests/components/usb_host/test.esp32-s3-idf.yaml index a71140feca3..c0acbe288f3 100644 --- a/tests/components/usb_host/test.esp32-s3-idf.yaml +++ b/tests/components/usb_host/test.esp32-s3-idf.yaml @@ -9,3 +9,4 @@ usb_host: pid: 0xABCD manufacturer: Example Corp product: Example Widget + interface_description: Example Port diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 02500035cfd..ac4dd37719a 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -75,6 +75,7 @@ usb_uart: pid: 0x4001 manufacturer: Nabu Casa product: ZBT-2 + interface_description: Zigbee channels: - id: channel_9_1 - id: uart_10 diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 9cf8382cb4c..6a5c00b2f73 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -54,6 +54,11 @@ voice_assistant: channels: 0 speaker: va_speaker_id micro_wake_word: mww_id + wake_word_audio: + buffer_duration: 1500ms + timeout: 2s + on_wake_word_rejected: + - logger.log: "Wake word rejected" conversation_timeout: 60s tts_playback_start_timeout: 10s on_listening: diff --git a/tests/components/web_server/test-ota.esp32-idf.yaml b/tests/components/web_server/test-ota.esp32-idf.yaml index b3496e01ebf..cece9cc9c51 100644 --- a/tests/components/web_server/test-ota.esp32-idf.yaml +++ b/tests/components/web_server/test-ota.esp32-idf.yaml @@ -14,6 +14,10 @@ web_server: port: 8080 version: 2 include_internal: true + # "*" compiles the origin check out; PNA still echoes the request origin + enable_private_network_access: true + allowed_origins: + - "*" # Enable debug logging for OTA logger: diff --git a/tests/components/web_server_base/test_origin.cpp b/tests/components/web_server_base/test_origin.cpp new file mode 100644 index 00000000000..9ebce5c1477 --- /dev/null +++ b/tests/components/web_server_base/test_origin.cpp @@ -0,0 +1,74 @@ +#include + +#include + +#include "esphome/components/web_server_base/origin.h" + +namespace esphome::web_server_base::testing { + +static bool allowed(const char *origin, const char *host, const char *const *list) { + return is_origin_allowed(origin, origin == nullptr ? 0 : strlen(origin), host, host == nullptr ? 0 : strlen(host), + list); +} + +static constexpr const char *LIST[] = {"https://app.esphome.io", "http://other.local:8080", nullptr}; +static constexpr const char *STAR[] = {"*", nullptr}; + +TEST(WebServerBaseOrigin, NoOriginIsAllowed) { + EXPECT_TRUE(allowed(nullptr, nullptr, nullptr)); + EXPECT_TRUE(allowed("", "device.local", nullptr)); +} + +TEST(WebServerBaseOrigin, SameOriginIsAllowedWithoutList) { + EXPECT_TRUE(allowed("http://device.local", "device.local", nullptr)); + EXPECT_TRUE(allowed("http://192.168.1.5:8080", "192.168.1.5:8080", nullptr)); + EXPECT_TRUE(allowed("https://device.example.com", "device.example.com", nullptr)); +} + +TEST(WebServerBaseOrigin, CrossOriginIsRejectedWithoutList) { + EXPECT_FALSE(allowed("http://evil.example", "device.local", nullptr)); + // Port or prefix differences are a different origin + EXPECT_FALSE(allowed("http://device.local:81", "device.local", nullptr)); + EXPECT_FALSE(allowed("http://device.local", "device.local:81", nullptr)); + EXPECT_FALSE(allowed("http://device.localx", "device.local", nullptr)); + EXPECT_FALSE(allowed("http://device.local", nullptr, nullptr)); + // Opaque origins such as sandboxed frames and file:// pages + EXPECT_FALSE(allowed("null", "null", nullptr)); +} + +TEST(WebServerBaseOrigin, ListedOriginIsAllowed) { + EXPECT_TRUE(allowed("https://app.esphome.io", "device.local", LIST)); + EXPECT_TRUE(allowed("http://other.local:8080", "device.local", LIST)); + EXPECT_TRUE(allowed("http://device.local", "device.local", LIST)); +} + +TEST(WebServerBaseOrigin, UnlistedOriginIsRejected) { + EXPECT_FALSE(allowed("https://app.esphome.io.evil", "device.local", LIST)); + EXPECT_FALSE(allowed("https://app.esphome.i", "device.local", LIST)); + EXPECT_FALSE(allowed("http://other.local", "device.local", LIST)); + EXPECT_FALSE(allowed("null", "device.local", LIST)); +} + +TEST(WebServerBaseOrigin, StarEntryIsNotAWildcard) { + // Codegen resolves "*" to USE_WEBSERVER_ALLOW_ANY_ORIGIN; the table itself never matches any origin + EXPECT_FALSE(allowed("http://evil.example", "device.local", STAR)); + EXPECT_FALSE(allowed("null", "device.local", STAR)); +} + +TEST(WebServerBaseOrigin, SchemeSeparatorMustFollowFirstColon) { + EXPECT_FALSE(allowed("http:", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:/", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:/device.local", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:device.local", "device.local", nullptr)); +} + +TEST(WebServerBaseOrigin, LengthsAreNotNulTerminated) { + // Arduino and ESP-IDF header values are passed with their length, not relied on for a terminator + const char origin[] = "https://app.esphome.ioXXXX"; + EXPECT_TRUE(is_origin_allowed(origin, strlen("https://app.esphome.io"), "h", 1, LIST)); + const char host[] = "device.localXXXX"; + EXPECT_TRUE( + is_origin_allowed("http://device.local", strlen("http://device.local"), host, strlen("device.local"), nullptr)); +} + +} // namespace esphome::web_server_base::testing diff --git a/tests/components/wifi/retry_phase_test.cpp b/tests/components/wifi/retry_phase_test.cpp index b95cbf73a92..50a43e6ea41 100644 --- a/tests/components/wifi/retry_phase_test.cpp +++ b/tests/components/wifi/retry_phase_test.cpp @@ -66,5 +66,18 @@ TEST_F(WiFiRetryPhaseTest, NetworkMissingFromScanIsTriedAsHidden) { EXPECT_FALSE(host_hooks.last_connect.has_bssid()); } +TEST_F(WiFiRetryPhaseTest, PinnedBssidMissingFromScanRestartsAtOnce) { + WiFiAP ap = make_ap("home"); + ap.set_bssid(BSSID_A); + this->add_network_(ap); + + // The SSID is visible, but on another access point, so no hidden network is left to try + this->scan_({make_result("home", BSSID_B)}); + + // The adapter restarts now instead of after the scan timeout + EXPECT_EQ(host_hooks.station_offs, 1); + EXPECT_EQ(host_hooks.connects, 0); +} + } // namespace } // namespace esphome::wifi::testing diff --git a/tests/integration/fixtures/api_event_order_backpressure.yaml b/tests/integration/fixtures/api_event_order_backpressure.yaml index 7b441883d98..cad66474f19 100644 --- a/tests/integration/fixtures/api_event_order_backpressure.yaml +++ b/tests/integration/fixtures/api_event_order_backpressure.yaml @@ -11,11 +11,27 @@ api: - action: fire_first_then_second then: - lambda: |- - // The client is not reading: about 110 KB of log lines fill the TCP pipe, then the overflow buffer + // The client is not reading: log lines fill the TCP pipe, then the overflow buffer. Until the + // client's receive window is closed, a late acknowledgement can still free send buffer space + // and let the overflow buffer drain, so keep going until it holds data with the window closed + auto *client = sndbuf_pin::api_log_client(); const std::string padding(250, 'x'); - for (int i = 0; i < 400; i++) { - ESP_LOGD("fill", "fill line %s", padding.c_str()); + const uint32_t start = millis(); + bool full = false; + while (client != nullptr && millis() - start < 5000) { + const bool window_closed = sndbuf_pin::api_peer_window_closed(); + if (client->try_to_clear_buffer(false)) { + ESP_LOGD("fill", "fill line %s", padding.c_str()); + } else if (window_closed) { + full = true; + break; + } else { + delay(1); + } } + // The api drops this line while the pipe is blocked; the test reads it from the console + if (!full) + ESP_LOGE("fill", "fill timed out"); // The test checks that this line was dropped, which means the overflow buffer still holds data ESP_LOGD("fill", "last fill line"); # So this event waits in the batch diff --git a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp index dbcf5922c2b..59a75f3b044 100644 --- a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp +++ b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp @@ -7,6 +7,12 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +// Host only: clang-tidy also checks this file for ESP32, which has no Linux socket headers +#if defined(USE_HOST) && defined(__linux__) +#include +#include +#endif + namespace esphome::sndbuf_pin { static const char *const TAG = "sndbuf_pin"; @@ -52,4 +58,35 @@ void SndbufPinComponent::setup() { } } +api::APIConnection *api_log_client() { + for (const auto &conn : api::global_api_server->active_clients()) { + if (conn->get_log_subscription_level() > 0) + return conn.get(); + } + return nullptr; +} + +bool api_peer_window_closed() { +#if defined(USE_HOST) && defined(__linux__) + for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) { + struct sockaddr_in addr{}; + socklen_t addr_len = sizeof(addr); + if (::getsockname(fd, reinterpret_cast(&addr), &addr_len) != 0 || + ntohs(addr.sin_port) != api::global_api_server->get_port()) + continue; + // Skips the listener: only the accepted client socket has a peer + addr_len = sizeof(addr); + if (::getpeername(fd, reinterpret_cast(&addr), &addr_len) != 0) + continue; + int queued = 0; + int not_sent = 0; + if (::ioctl(fd, SIOCOUTQ, &queued) != 0 || ::ioctl(fd, SIOCOUTQNSD, ¬_sent) != 0) + return false; + return not_sent > 0 && queued == not_sent; + } +#endif + // Unknown elsewhere: a fill loop waiting on this times out and says so + return false; +} + } // namespace esphome::sndbuf_pin diff --git a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h index b0af226d143..cc5f6096c88 100644 --- a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h +++ b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/api/api_connection.h" #include "esphome/core/component.h" namespace esphome::sndbuf_pin { @@ -18,4 +19,13 @@ class SndbufPinComponent : public Component { int buffer_size_; }; +/// The api client subscribed to logs, or nullptr. A fixture lambda cannot name the api namespace: +/// the test harness rewrites every "api" key followed by a colon in the YAML. +api::APIConnection *api_log_client(); + +/// True once the api client's receive window is closed: everything sent is acknowledged and data still +/// waits to be sent, so no acknowledgement can free send buffer space until the client reads again. +/// Linux host only; always false elsewhere. +bool api_peer_window_closed(); + } // namespace esphome::sndbuf_pin diff --git a/tests/integration/fixtures/store_yaml_recovery.yaml b/tests/integration/fixtures/store_yaml_recovery.yaml new file mode 100644 index 00000000000..c416e41f50f --- /dev/null +++ b/tests/integration/fixtures/store_yaml_recovery.yaml @@ -0,0 +1,18 @@ +esphome: + name: store-yaml-test + areas: + - id: living_room + name: "Living Room" + +host: + +logger: + +api: + +ota: + - platform: esphome + password: recoverme123 + +store_yaml: + allow_unencrypted: true diff --git a/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml b/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml new file mode 100644 index 00000000000..e2b0861a485 --- /dev/null +++ b/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml @@ -0,0 +1,24 @@ +esphome: + name: store-yaml-backpressure + +host: + +logger: + +api: + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [sndbuf_pin_component] + +# Pins the device's socket send buffers for deterministic TCP backpressure +sndbuf_pin_component: + buffer_size: SERVER_SNDBUF + +store_yaml: + allow_unencrypted: true + +substitutions: +# GENERATED_SUBSTITUTIONS diff --git a/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml b/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml index a43e5b5ae34..7c66e058436 100644 --- a/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml +++ b/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml @@ -22,9 +22,14 @@ uart_mock: id: mock_uart baud_rate: 2400 auto_start: false + # The switches enable the QPIRI poll, sent once at boot. A bare CR fails it at + # once, so queued commands don't wait out the 5 s poll timeout + responses: + - expect_tx: [0x51, 0x50, 0x49, 0x52, 0x49] + inject_rx: [0x0D] -# No polling, so only the switch writes reach the command queue and no -# polled status publishes the switch states +# No polling after boot, so only the switch writes reach the command queue and +# no polled status publishes the switch states pipsolar: id: inverter0 uart_id: mock_uart diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index f8f5184dc19..1327c783135 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -1,7 +1,8 @@ """Minimal plaintext native-api client over a raw socket. Reads only when told to, so tests control when the TCP pipe backs up toward -the device; message types are counted and each frame is kept for inspection. +the device; message types are counted and each frame is kept for inspection, +and read_frame() hands them out in arrival order. """ from __future__ import annotations @@ -137,6 +138,7 @@ class RawApiClient: def __init__(self, port: int, recv_buffer_size: int | None = None) -> None: self._port = port self._parser = FrameParser() + self._read_pos = 0 # frames before this index were consumed by read_frame() self.bytes_received = 0 self.frame_counts: Counter[int] = Counter() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -179,21 +181,38 @@ class RawApiClient: loop = asyncio.get_running_loop() await loop.sock_sendall(self._sock, encode_frame(msg_type, payload)) + async def _recv_once(self) -> None: + data = await asyncio.get_running_loop().sock_recv(self._sock, _READ_CHUNK) + assert data, "server closed the connection unexpectedly" + self.bytes_received += len(data) + self.frame_counts.update(self._parser.feed(data)) + async def read_until_frame( self, msg_type: int, timeout: float = 10.0, count: int = 1 ) -> None: """Read until at least count frames of msg_type have been received.""" - loop = asyncio.get_running_loop() async def _read_loop() -> None: while self.frame_counts[msg_type] < count: - data = await loop.sock_recv(self._sock, _READ_CHUNK) - assert data, "server closed the connection unexpectedly" - self.bytes_received += len(data) - self.frame_counts.update(self._parser.feed(data)) + await self._recv_once() await asyncio.wait_for(_read_loop(), timeout) + async def read_frame(self, msg_type: int, timeout: float = 10.0) -> bytes: + """Return the oldest unconsumed payload of msg_type, skipping earlier frames of other types.""" + frames = self._parser.frames + + async def _read_loop() -> bytes: + while True: + while self._read_pos < len(frames): + frame_type, payload = frames[self._read_pos] + self._read_pos += 1 + if frame_type == msg_type: + return payload + await self._recv_once() + + return await asyncio.wait_for(_read_loop(), timeout) + def frames(self, msg_type: int) -> list[bytes]: """Payloads of every frame of msg_type received so far.""" return [payload for kind, payload in self._parser.frames if kind == msg_type] diff --git a/tests/integration/test_api_event_order_backpressure.py b/tests/integration/test_api_event_order_backpressure.py index eed42b50dcc..6c06f846937 100644 --- a/tests/integration/test_api_event_order_backpressure.py +++ b/tests/integration/test_api_event_order_backpressure.py @@ -72,6 +72,9 @@ async def test_api_event_order_backpressure( # The client stops reading here, so the device console tells when "first" fired await client.send_message(api_pb2.ExecuteServiceRequest(key=action_key)) await waiter.wait_for("first fired", timeout=10) + assert not any("fill timed out" in line for line in waiter.lines), ( + "the client's receive window never closed while the overflow buffer held data" + ) # The client reads again while the action still runs. # On a timeout the asserts below show which event is missing diff --git a/tests/integration/test_store_yaml_recovery.py b/tests/integration/test_store_yaml_recovery.py new file mode 100644 index 00000000000..a8081f83d29 --- /dev/null +++ b/tests/integration/test_store_yaml_recovery.py @@ -0,0 +1,109 @@ +"""End-to-end test of `store_yaml` recovery over the native API. + +Uses the raw client so a test controls when it reads; aioesphomeapi has no get_yaml helper. +""" + +from __future__ import annotations + +import asyncio +import random + +from aioesphomeapi import api_pb2 +import pytest + +from esphome.components.store_yaml import unpack_envelope +from esphome.helpers import zstd_module +from esphome.yaml_util import find_secret_references + +from .log_utils import LineWaiter +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient +from .types import RunCompiledFunction + +GET_YAML_RESPONSE = MESSAGE_TYPE_OF[api_pb2.GetYamlResponse] + + +async def _read_yaml_stream(client: RawApiClient) -> list[api_pb2.GetYamlResponse]: + """Read GetYamlResponse chunks up to and including the one marked done.""" + responses: list[api_pb2.GetYamlResponse] = [] + while not responses or not responses[-1].done: + payload = await client.read_frame(GET_YAML_RESPONSE) + responses.append(api_pb2.GetYamlResponse.FromString(payload)) + return responses + + +@pytest.mark.asyncio +async def test_store_yaml_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + """Stream the embedded YAML back from a host build and check the recovered files.""" + async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client: + await client.connect("store_yaml integration test") + + # Clients learn the device can answer get_yaml from its capabilities. + await client.send_message(api_pb2.DeviceCapabilitiesRequest()) + capabilities = api_pb2.DeviceCapabilitiesResponse.FromString( + await client.read_frame(MESSAGE_TYPE_OF[api_pb2.DeviceCapabilitiesResponse]) + ) + assert capabilities.store_yaml.supported + + await client.send_message(api_pb2.GetYamlRequest()) + responses = await _read_yaml_stream(client) + + compressed = b"".join(r.data for r in responses) + assert responses[0].total_size == len(compressed) + + envelope = zstd_module().decompress(compressed) + files = unpack_envelope(envelope) + combined = b"\n".join(files.values()) + assert b"store-yaml-test" in combined + assert b"store_yaml:" in combined + # The inline OTA password comes back as a !secret listed in the skeleton. + assert b"recoverme123" not in envelope + assert "ota_password" in find_secret_references(combined.decode()) + assert b"ota_password: ''" in files["secrets.yaml"] + + +# Random hex barely compresses, so the blob spans many chunks and outgrows both buffers +SERVER_SNDBUF = 8192 +RECV_BUFFER_SIZE = 4096 +CHUNK_LIMIT = 1390 + + +@pytest.mark.asyncio +async def test_store_yaml_recovery_backpressure( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + """A client that stops reading mid-stream still recovers every byte once it drains.""" + rng = random.Random(16445) + values = {f"blob_{i}": rng.randbytes(60).hex() for i in range(1000)} + config = yaml_config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF)).replace( + "# GENERATED_SUBSTITUTIONS", + "\n".join(f" {name}: {value}" for name, value in values.items()), + ) + log = LineWaiter() + async with ( + run_compiled(config, line_callback=log.callback), + RawApiClient(unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE) as client, + ): + # Without the pin the kernel buffers could swallow the stall + await log.wait_for("SO_SNDBUF pinned to", timeout=10.0) + await client.connect("store_yaml backpressure test") + await client.send_message(api_pb2.GetYamlRequest()) + # Stop reading so the device fills both buffers and has to resume later + await asyncio.sleep(0.5) + responses = await _read_yaml_stream(client) + + chunks = [r.data for r in responses] + compressed = b"".join(chunks) + assert len(compressed) > 4 * (SERVER_SNDBUF + RECV_BUFFER_SIZE) + assert len(responses) > 1 and all(len(c) <= CHUNK_LIMIT for c in chunks) + assert not any(r.done for r in responses[:-1]) + assert responses[0].total_size == len(compressed) + combined = b"\n".join( + unpack_envelope(zstd_module().decompress(compressed)).values() + ) + assert all(value.encode() in combined for value in values.values()) diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 56ded92bcd8..6c45a358318 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -188,18 +188,21 @@ def test_convert_emits_key_constraints() -> None: ] -def test_merge_keeps_base_and_local_constraints() -> None: +def test_overlay_field_keeps_base_and_local_constraints() -> None: base = {"schema": {"constraints": [{"type": "key_requires", "keys": ["a"]}]}} local = {"schema": {"constraints": [{"type": "only_when", "keys": ["b"]}]}} - assert _bls.merge(base, local)["schema"]["constraints"] == [ + assert _bls.overlay_field(local, base)["schema"]["constraints"] == [ {"type": "key_requires", "keys": ["a"]}, {"type": "only_when", "keys": ["b"]}, ] -def test_merge_enum_option_named_constraints() -> None: +def test_overlay_field_enum_option_named_constraints() -> None: base = {"values": {"constraints": None}} - assert _bls.merge(base, {"values": {}}) == {"values": {"constraints": None}} + assert _bls.overlay_field({"key": "Optional"}, base) == { + "key": "Optional", + "values": {"constraints": None}, + } def test_inline_schema_keeps_base_and_local_constraints() -> None: @@ -354,6 +357,17 @@ def test_uart_clock_source_preserves_variant_metadata( assert "ESP32C6" not in clock_source["values"]["APB"]["variants"] +def test_automation_with_one_extra_key_keeps_it(language_schema_dir: Path) -> None: + """A trigger whose only extra key is not trigger_id still lists that key.""" + modbus_schema = json.loads((language_schema_dir / "modbus_client.json").read_text()) + on_no_response = modbus_schema["modbus_client"]["schemas"]["_ACTION_BASE_SCHEMA"][ + "schema" + ]["config_vars"]["on_no_response"] + + assert on_no_response["type"] == "trigger" + assert set(on_no_response["schema"]["config_vars"]) == {"retry", "then"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # @@ -439,3 +453,292 @@ def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: _count(lvgl_schema) assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" + + +def test_convert_typed_schema_with_an_empty_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A typed_schema type with no keys of its own is dumped with no config vars.""" + types = { + "with_keys": cv.Schema({cv.Required("byte"): cv.hex_uint8_t}), + "no_keys": cv.Schema({}), + } + schema = cv.typed_schema(types, key="mode") + # cv.typed_schema was decorated before the script enabled extraction, so register it by hand + monkeypatch.setitem( + _bls.ejs.typed_schemas, repr(schema), ((types,), {"key": "mode"}) + ) + config_var: dict = {} + _bls.convert(schema, config_var, "/test") + + assert config_var["type"] == "typed" + assert config_var["typed_key"] == "mode" + assert "byte" in config_var["types"]["with_keys"]["config_vars"] + assert config_var["types"]["no_keys"] == {"config_vars": {}} + + +def _schema(config_vars: dict | None = None, extends: list | None = None) -> dict: + schema: dict = {} + if config_vars is not None: + schema["config_vars"] = config_vars + if extends is not None: + schema["extends"] = extends + return {"type": "schema", "schema": schema} + + +def test_splice_extends_drops_the_key_when_nothing_remains() -> None: + node: dict = {"extends": []} + _bls.splice_extends(node, ["b"], "b") + assert node == {} + + +def _shrink(monkeypatch: pytest.MonkeyPatch, output: dict) -> None: + monkeypatch.setattr(_bls, "output", output) + monkeypatch.setattr(_bls, "components", {}) + _bls.shrink() + + +def test_shrink_inlining_one_extend_keeps_the_others( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inlining a once-referenced schema keeps the target's other extends.""" + output = { + "base": {"schemas": {"SHARED": _schema({"name": {"key": "Optional"}})}}, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}), + "CONFIG_SCHEMA": _schema({}, ["base.SHARED", "mod.ONCE"]), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED"])}}, + } + _shrink(monkeypatch, output) + + schema = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"] + assert schema["extends"] == ["base.SHARED"] + assert "extra" in schema["config_vars"] + + +def test_shrink_drops_a_bare_validator_beside_config_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An empty extend next to real keys (cv.All(SCHEMA, check)) is dropped, not collapsed.""" + output = { + "mod": { + "schemas": { + "check": {}, + "CONFIG_SCHEMA": _schema( + {"address": {"key": "Required"}}, ["mod.check"] + ), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema(None, ["mod.check"])}}, + } + _shrink(monkeypatch, output) + + assert output["mod"]["schemas"]["CONFIG_SCHEMA"] == _schema( + {"address": {"key": "Required"}} + ) + assert output["other"]["schemas"]["CONFIG_SCHEMA"] == {"data_type": "check"} + + +def test_shrink_inlined_schema_brings_its_own_extends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The inlined schema's extends take its place beside the target's siblings.""" + output = { + "base": { + "schemas": { + "SHARED": _schema({"name": {"key": "Optional"}}), + "INNER": _schema({"inner": {"key": "Optional"}}), + } + }, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}, ["base.INNER"]), + "CONFIG_SCHEMA": _schema( + {"local": {"key": "Optional"}}, ["base.SHARED", "mod.ONCE"] + ), + } + }, + "other": { + "schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED", "base.INNER"])} + }, + } + _shrink(monkeypatch, output) + + schema = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"] + assert schema["extends"] == ["base.SHARED", "base.INNER"] + assert set(schema["config_vars"]) == {"local", "extra"} + + +def test_shrink_inlines_into_a_typed_variant(monkeypatch: pytest.MonkeyPatch) -> None: + """A typed variant's top-level extends keep the sibling, the inherited base and local keys.""" + output = { + "base": { + "schemas": { + "SHARED": _schema({"name": {"key": "Optional"}}), + "INNER": _schema({"inner": {"key": "Optional"}}), + } + }, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}, ["base.INNER"]), + "CONFIG_SCHEMA": { + "type": "typed", + "types": { + "v1": { + "config_vars": {"local": {"key": "Optional"}}, + "extends": ["base.SHARED", "mod.ONCE"], + } + }, + }, + } + }, + "other": { + "schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED", "base.INNER"])} + }, + } + _shrink(monkeypatch, output) + + variant = output["mod"]["schemas"]["CONFIG_SCHEMA"]["types"]["v1"] + assert variant["extends"] == ["base.SHARED", "base.INNER"] + assert set(variant["config_vars"]) == {"local", "extra"} + + +def test_shrink_spread_keeps_enum_values_beside_a_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An enum spliced beside a plain schema keeps its values; alone it is folded in.""" + enum = {"type": "enum", "values": {"a": None}} + output = { + "base": { + "schemas": {"E": enum, "B": _schema({"b": {"key": "Optional"}})}, + }, + "mod": { + "schemas": { + "CONFIG_SCHEMA": _schema({"mode": _schema(None, ["base.E", "base.B"])}) + } + }, + "other": { + "schemas": { + "CONFIG_SCHEMA": _schema( + { + "mode": _schema(None, ["base.E"]), + "b": _schema(None, ["base.B"]), + } + ) + } + }, + } + _shrink(monkeypatch, output) + + mod_vars = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"] + other_vars = output["other"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"] + assert mod_vars["mode"] == {**_schema(None, ["base.B"]), "values": {"a": None}} + assert other_vars["mode"] == enum + + +def test_gree_feature_switches_extend_the_switch_schema( + language_schema_dir: Path, +) -> None: + """Gree's closure-built feature switches export the switch schema.""" + gree = json.loads((language_schema_dir / "gree.json").read_text()) + config_vars = gree["gree.switch"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ] + for key in ("health", "light", "turbo", "xfan"): + assert config_vars[key]["schema"]["extends"] == ["switch._SWITCH_SCHEMA"] + + +def test_inline_schema_merges_a_same_key_override_per_field() -> None: + """A local override keeps the base field's type and values, and the base is not mutated.""" + base = { + "config_vars": { + "mode": { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + } + } + } + node = {"config_vars": {"mode": {"default": "b"}}, "extends": ["x"]} + _bls.inline_schema(node, base) + + assert node["config_vars"]["mode"] == { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + "default": "b", + } + assert "default" not in base["config_vars"]["mode"] + + +def test_inline_schema_lets_a_narrowed_enum_replace_the_base_values() -> None: + """An explicitly overridden values map replaces the base's instead of merging with it.""" + base = { + "config_vars": { + "mode": { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + } + } + } + node = {"config_vars": {"mode": {"values": {"b": None}}}} + _bls.inline_schema(node, base) + + assert node["config_vars"]["mode"] == { + "key": "Optional", + "type": "enum", + "values": {"b": None}, + } + + +def test_inline_schema_overlays_nested_schema_fields() -> None: + """A nested schema's config vars overlay per field too.""" + base = { + "config_vars": { + "outer": { + "type": "schema", + "schema": { + "config_vars": {"inner": {"key": "Optional", "type": "boolean"}} + }, + } + } + } + node = { + "config_vars": { + "outer": {"schema": {"config_vars": {"inner": {"default": "True"}}}} + } + } + _bls.inline_schema(node, base) + + assert node["config_vars"]["outer"]["schema"]["config_vars"]["inner"] == { + "key": "Optional", + "type": "boolean", + "default": "True", + } + + +def test_shrink_enum_collapse_reports_dropped_siblings( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Collapsing into an enum prints the sibling extends it cannot keep.""" + output = { + "base": {"schemas": {"B": _schema({"b": {"key": "Optional"}})}}, + "mod": { + "schemas": { + "E": {"type": "enum", "values": {"a": None}}, + "CONFIG_SCHEMA": _schema({"mode": _schema(None, ["mod.E", "base.B"])}), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema({}, ["base.B"])}}, + } + _shrink(monkeypatch, output) + + mode = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"]["mode"] + assert mode == {"type": "enum", "values": {"a": None}} + assert "dropped ['base.B'] beside sibling extends at mod.schemas.CONFIG_SCHEMA" in ( + capsys.readouterr().out + ) diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 68e3cab2a21..99551e45e68 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -69,9 +69,11 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "serial_proxies": 4, "wizard": 5, "sendspin": 6, + "crash_report": 8, }, "WizardCapabilities": {"configured": 1}, "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, + "CrashReportCapabilities": {"flags": 1}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, @@ -484,6 +486,52 @@ def test_sendspin_pairing_token_rpc_requires_authentication() -> None: # ==================== Group C: advertised API version ==================== +def test_crash_report_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("CrashReportRequest", 163), + ("CrashReportResponse", 164), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_crash_report_response_fields_keep_their_wire_numbers() -> None: + """Clients decode the response purely by field number.""" + for message, fields in ( + ( + "CrashReportResponse", + ( + ("flags", 1), + ("reason", 2), + ("pc", 3), + ("cause", 4), + ("fault_address", 5), + ("build_time", 6), + ("crashed_core", 7), + ("cores", 8), + ), + ), + ("CrashReportCore", (("core", 1), ("backtrace", 2))), + ): + body = _extract_proto_message(PROTO_TEXT, message) + for field_name, number in fields: + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in {message} is no longer field number {number}" + ) + + +def test_crash_report_rpc_requires_authentication() -> None: + """The rpc inherits the default of needs_authentication = true.""" + body = _extract_rpc_body(PROTO_TEXT, "crash_report") + assert "needs_authentication" not in body + + def test_api_version_minor_is_at_least_15() -> None: """Clients gate sending DeviceCapabilitiesRequest on seeing api_version >= 1.15 in HelloResponse. Regressing api_version_minor below diff --git a/tests/unit_tests/components/test_store_yaml.py b/tests/unit_tests/components/test_store_yaml.py new file mode 100644 index 00000000000..99937ebb738 --- /dev/null +++ b/tests/unit_tests/components/test_store_yaml.py @@ -0,0 +1,1312 @@ +"""Tests for store_yaml file gathering, redaction and envelope packing.""" + +from __future__ import annotations + +from pathlib import Path +import re +import struct +import sys + +import pytest +import yaml + +from esphome import yaml_util +from esphome.components import packages, store_yaml +from esphome.components.store_yaml import ( + CONF_ALLOW_UNENCRYPTED, + ENVELOPE_MAGIC, + SECRETS_SKELETON_HEADER, + UNCAPTURED_NOTE_PATH, + _build_secrets_skeleton, + _final_validate, + _gather_files, + _generate_redacted_files, + _iter_nodes, + _pack_envelope, + _read_files_verbatim, + _remote_package_descriptions, + _symlink_descriptions, + _uncaptured_note, + unpack_envelope, +) +import esphome.config_validation as cv +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv +from esphome.helpers import add_class_to_obj +from esphome.yaml_util import ( + DiscoveredYamlFiles, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + discover_user_yaml_files, +) + +WIFI_PW = {"wifi": {"networks": [{"password": SensitiveStr("hunter2")}]}} +API_KEY = {"api": {"encryption": {"key": SensitiveStr("SUPER_SECRET")}}} +_PKG_PW = "packages:\n common: !include pkg/common.yaml\n" +_SECRET_VAR = "wifi: !include {file: wifi.yaml, vars: {pw: !secret wifi_password}}\n" +_OUTER_VAR = "packages:\n outer: !include {file: outer.yaml, vars: {pw: !secret wifi_password}}\n" +_WIFI_SECRET = "wifi_password: hunter2\n" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + """An entry yaml, an include and a secrets file as the validated config's root.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "entry.yaml").write_text( + "esphome:\n name: test\napi:\n encryption:\n key: !secret api_key\n" + ) + (project_dir / "wifi.yaml").write_text("ssid: my_ssid\npassword: my_password\n") + (project_dir / "secrets.yaml").write_text("api_key: SUPER_SECRET\n") + CORE.config_path = project_dir / "entry.yaml" + CORE.config = {} + return project_dir + + +@pytest.fixture +def shared(tmp_path: Path) -> Path: + shared = tmp_path / "shared" + shared.mkdir() + return shared + + +def _sources( + project_dir: Path, + *names: str, + secrets: tuple[str, ...] = (), + unresolved: list[str] | None = None, + load_errors: list[str] | None = None, +) -> DiscoveredYamlFiles: + files = [(project_dir / name).resolve() for name in names] + return DiscoveredYamlFiles( + files, + {(project_dir / name).resolve() for name in secrets}, + unresolved or [], + load_errors or [], + {path: [path] for path in files}, + ) + + +def _gather_redacted(discovered: DiscoveredYamlFiles) -> dict[str, bytes]: + entries, secret_rels = _gather_files(discovered) + return dict( + _generate_redacted_files(entries, secret_rels, _remote_package_descriptions()) + ) + + +def _redact( + project: Path, files: dict[str, str], config: dict | None = None +) -> dict[str, bytes]: + """Write `files`, register the secrets file as discovery would, and redact.""" + for name, text in files.items(): + (project / name).parent.mkdir(parents=True, exist_ok=True) + (project / name).write_text(text) + if config is not None: + CORE.config = config + secrets = yaml.safe_load((project / "secrets.yaml").read_text()) or {} + for name, value in secrets.items(): + yaml_util._SECRET_VALUES[str(value)] = name + return _gather_redacted( + _sources(project, *files, "secrets.yaml", secrets=("secrets.yaml",)) + ) + + +def _run_final_validate(full_config: dict, config: dict) -> dict: + token = fv.full_config.set(full_config) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_gather_maps_rel_paths_and_reads_verbatim(project: Path) -> None: + """Entries map to root-relative paths; `include_secrets: true` keeps the bytes.""" + discovered = _sources( + project, "entry.yaml", "secrets.yaml", secrets=("secrets.yaml",) + ) + entries, secret_rels = _gather_files(discovered) + assert dict(entries) == { + "entry.yaml": project / "entry.yaml", + "secrets.yaml": project / "secrets.yaml", + } + assert secret_rels == {"secrets.yaml"} + contents = dict(_read_files_verbatim(entries)) + assert contents["secrets.yaml"] == (project / "secrets.yaml").read_bytes() + + +def test_gather_uses_relative_path_for_external_files( + project: Path, tmp_path: Path +) -> None: + """Files outside the project root keep a ``..`` path.""" + sibling = tmp_path / "outside.yaml" + sibling.write_text("foo: bar\n") + discovered = _sources(project, "entry.yaml") + discovered.files.append(sibling) + files, _ = _gather_files(discovered) + assert "../outside.yaml" in dict(files) + + +def test_gather_raises_on_load_errors(project: Path) -> None: + discovered = _sources(project, "entry.yaml", load_errors=["oops.yaml: boom"]) + with pytest.raises(EsphomeError, match="oops.yaml"): + _gather_files(discovered) + + +def test_read_files_verbatim_raises_on_unreadable_file( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + entries, _ = _gather_files(_sources(project, "entry.yaml", "wifi.yaml")) + orig_read_bytes = Path.read_bytes + + def fake_read_bytes(self: Path) -> bytes: + if self.name == "wifi.yaml": + raise OSError("permission denied") + return orig_read_bytes(self) + + monkeypatch.setattr(Path, "read_bytes", fake_read_bytes) + with pytest.raises(EsphomeError, match="wifi.yaml"): + _read_files_verbatim(entries) + + +def test_gather_warns_on_unresolved_includes( + project: Path, caplog: pytest.LogCaptureFixture +) -> None: + discovered = _sources(project, "entry.yaml", unresolved=["${board}.yaml"]) + with caplog.at_level("WARNING", logger="esphome.components.store_yaml"): + files, _ = _gather_files(discovered) + assert len(files) == 1 + assert any( + "${board}.yaml" in r.message and "not contain" in r.message + for r in caplog.records + ) + + +def test_gather_raises_esphome_error_on_cross_anchor_path( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A path with no common anchor raises EsphomeError, not ValueError.""" + + def fake_relative_to(self: Path, other: Path, walk_up: bool = False) -> Path: + raise ValueError("paths have different anchors") + + monkeypatch.setattr(Path, "relative_to", fake_relative_to) + with pytest.raises(EsphomeError, match="does not share a root"): + _gather_files(_sources(project, "entry.yaml")) + + +def test_redacted_secrets_file_becomes_skeleton(project: Path) -> None: + files = _redact(project, {"entry.yaml": (project / "entry.yaml").read_text()}) + skeleton = files["secrets.yaml"].decode() + assert skeleton.startswith(SECRETS_SKELETON_HEADER) + assert "api_key: ''" in skeleton + assert b"SUPER_SECRET" not in b"".join(files.values()) + assert "key: !secret 'api_key'" in files["entry.yaml"].decode() + + +def test_redacted_symlinked_secrets_outside_root(project: Path, tmp_path: Path) -> None: + """A secrets.yaml linked to another name outside the root gets a root skeleton.""" + target = tmp_path / "actual_creds.yaml" + target.write_text("api_key: SUPER_SECRET\n") + link = project / "secrets.yaml" + link.unlink() + link.symlink_to(target) + resolved = link.resolve() + files = _gather_redacted( + DiscoveredYamlFiles([project / "entry.yaml", resolved], {resolved}) + ) + assert "api_key: ''" in files["secrets.yaml"].decode() + assert b"SUPER_SECRET" not in b"".join(files.values()) + + +@pytest.mark.parametrize( + ("source", "config", "expected", "name"), + [ + pytest.param( + "password: 'my_password'\n", + {"wifi": [{"password": SensitiveStr("my_password")}]}, + b"password: !secret 'wifi_password'\n", + "wifi_password", + id="string", + ), + pytest.param( + "password: word\n", + {"wifi": [{"password": SensitiveStr("word")}]}, + b"password: !secret 'wifi_password'\n", + "wifi_password", + id="value_inside_its_own_key_name", + ), + pytest.param( + "network_key: 0x00112233445566778899aabbccddeeff\nname: node 12345\n", + { + "openthread": { + "network_key": SensitiveHexInt(0x00112233445566778899AABBCCDDEEFF) + } + }, + b"network_key: !secret 'openthread_network_key'\nname: node 12345\n", + "openthread_network_key", + id="hex_int", + ), + pytest.param( + "network_key: 12345\nname: node 12345\n", + {"openthread": {"network_key": SensitiveInt(12345)}}, + b"network_key: !secret 'openthread_network_key'\nname: node 12345\n", + "openthread_network_key", + id="int", + ), + ], +) +def test_redacted_inline_value_becomes_secret_ref( + project: Path, source: str, config: dict, expected: bytes, name: str +) -> None: + """An inline sensitive value becomes a path-named !secret listed in the skeleton.""" + files = _redact(project, {"wifi.yaml": source}, config) + assert files["wifi.yaml"] == expected + assert f"{name}: ''" in files["secrets.yaml"].decode() + + +@pytest.mark.parametrize( + ("files", "config", "match"), + [ + pytest.param( + {"thread.yaml": 'network_key: "0x1234"\n'}, + {"openthread": {"network_key": SensitiveHexInt(0x1234)}}, + "network_key in thread.yaml", + id="quoted_int", + ), + pytest.param( + {"wifi.yaml": "ssid: my_ssid\n"}, + {"wifi": [{"password": SensitiveStr("not_in_any_file")}]}, + "wifi.password", + id="not_locatable", + ), + pytest.param( + { + "wifi.yaml": "password: my_password\nx: !lambda 'return \"my_password\";'\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded", + id="lambda", + ), + pytest.param( + { + "entry.yaml": "password: my_password\n" + "pkg: !include {file: wifi.yaml, vars: {url: 'http://user:my_password@host'}}\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded|URL with credentials", + id="include_vars", + ), + pytest.param( + { + "entry.yaml": "password: my_password\n" + "pkg: !include {file: wifi.yaml, condition: \"${ x == 'my_password' }\"}\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded", + id="include_condition", + ), + pytest.param( + { + "entry.yaml": "packages:\n - url: https://host/repo\n password: hunter2\n" + }, + {}, + "packages.password in entry.yaml", + id="private_package_password", + ), + pytest.param( + { + "pkg/common.yaml": "wifi:\n password: old_pw\n", + "entry.yaml": _PKG_PW + "wifi:\n password: new_pw\n", + }, + {"wifi": {"networks": [{"password": SensitiveStr("new_pw")}]}}, + "wifi.password in pkg/common.yaml", + id="overridden_package_password_regrouped_by_validation", + ), + pytest.param( + { + "pkg/common.yaml": "mqtt:\n password: old_pw\n", + "entry.yaml": _PKG_PW + + "wifi:\n password: hunter2\nmqtt:\n password: hunter2\n", + }, + {**WIFI_PW, "mqtt": {"password": SensitiveStr("hunter2")}}, + "mqtt.password in pkg/common.yaml", + id="shared_password_keeps_every_path", + ), + pytest.param( + { + "pkg/common.yaml": "mqtt:\n password: old_pw\n", + "entry.yaml": _PKG_PW + 'mqtt:\n password: ""\n', + }, + {"mqtt": {"password": SensitiveStr("")}}, + "mqtt.password in pkg/common.yaml", + id="overridden_by_empty_password", + ), + pytest.param( + {"wifi.yaml": "substitutions:\n prefix_hunter2: x\npassword: hunter2\n"}, + WIFI_PW, + "inside a mapping key under substitutions", + id="key_embeds_password", + ), + pytest.param( + { + "legacy.yaml": "mqtt:\n broker: b\n password: hunter2\n", + "entry.yaml": "packages:\n legacy: !include legacy.yaml\n" + "mqtt: !remove\nwifi:\n password: pw2\n", + }, + {"wifi": {"networks": [{"password": SensitiveStr("pw2")}]}}, + "mqtt.password in legacy.yaml", + id="removed_component_keeps_credential_keys", + ), + pytest.param( + { + "vpn.yaml": "wireguard:\n private_key: hunter2\n", + "entry.yaml": "packages:\n vpn: !include vpn.yaml\nwireguard: !remove\n", + }, + API_KEY, + "wireguard.private_key in vpn.yaml", + id="removed_component_credential_known_by_name", + ), + pytest.param( + { + "player.yaml": 'sendspin:\n static_pairing_code: "123456"\n', + "entry.yaml": "packages:\n p: !include player.yaml\nsendspin: !remove\n", + }, + API_KEY, + "sendspin.static_pairing_code in player.yaml", + id="removed_credential_outside_the_common_words", + ), + pytest.param( + { + "base.yaml": "wifi:\n ssid: Old\n password: old_password123\n", + "entry.yaml": "<<: !include base.yaml\nwifi:\n ssid: OpenNetwork\n", + }, + {"wifi": {"networks": [{"ssid": SensitiveStr("OpenNetwork")}]}}, + "wifi.password in base.yaml", + id="credential_shadowed_by_a_merge", + ), + pytest.param( + { + "base.yaml": "dsmr:\n decryption_key: 00112233445566778899AABBCCDDEEFF\n", + "entry.yaml": "<<: !include base.yaml\ndsmr:\n max_telegram_length: 1500\n", + }, + API_KEY, + "dsmr.decryption_key in base.yaml", + id="bind_key_shadowed_by_a_merge", + ), + pytest.param( + { + "pkg.yaml": "packet_transport:\n encryption: old_key_123\n", + "entry.yaml": "packages:\n p: !include pkg.yaml\n" + "packet_transport:\n encryption: !secret new_key\n", + "secrets.yaml": "new_key: new_key_456\n", + }, + {"packet_transport": {"encryption": {"key": SensitiveStr("new_key_456")}}}, + "packet_transport.encryption in pkg.yaml", + id="overridden_shorthand_key", + ), + pytest.param( + { + "scripts.yaml": "script:\n - id: old_script\n then:\n" + " - wifi.configure:\n ssid: x\n password: hunter2\n", + "entry.yaml": "packages:\n s: !include scripts.yaml\n" + "script:\n - id: !remove old_script\n", + }, + API_KEY, + "password in scripts.yaml", + id="list_item_removed_by_id", + ), + pytest.param( + { + "entry.yaml": "packages:\n vpn:\n wireguard:\n private_key: hunter2\n" + " off:\n wireguard: !remove\n" + }, + API_KEY, + "wireguard.private_key in entry.yaml", + id="removed_by_an_inline_package", + ), + pytest.param( + {"wifi.yaml": "wifi:\n hunter2:\n child: x\n"}, + {"ota": [{"password": SensitiveStr("hunter2")}]}, + "mapping key under wifi", + id="password_as_mapping_key", + ), + pytest.param( + { + "ld.yaml": "ld2410:\n bluetooth_password: !lambda 'return \"hunter2\";'\n" + }, + { + "ld2410": { + "bluetooth_password": add_class_to_obj( + Lambda('return "hunter2";'), yaml_util.Sensitive + ) + } + }, + "set by a lambda", + id="credential_from_a_lambda", + ), + pytest.param( + { + "ota.yaml": "password: !secret ota_pw\nurl: http://host/?pw=hunter2\n", + "secrets.yaml": "ota_pw: hunter2\n", + }, + {"ota": [{"password": SensitiveStr("hunter2")}]}, + "inside url in ota.yaml", + id="secret_inside_a_larger_value", + ), + pytest.param( + {"fp.yaml": "fingerprint_grow:\n password: !lambda 'return 12345678;'\n"}, + { + "fingerprint_grow": { + "password": add_class_to_obj( + Lambda("return 12345678;"), yaml_util.Sensitive + ) + } + }, + "set by a lambda", + id="credential_from_a_lambda_number", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n a: ${b}\n b: ${a}\nwifi:\n password: ${a}\n" + }, + WIFI_PW, + "cannot be confirmed", + id="alias_cycle", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n api:\n encryption:\n key: old_key\n" + "api:\n encryption:\n key: !secret api_key\n" + }, + API_KEY, + "packages.base.api.encryption.key", + id="inline_package_override", + ), + pytest.param( + { + "pkg/common.yaml": "substitutions:\n pkg_pw: !secret pw\n", + "entry.yaml": "substitutions:\n pkg_pw: hunter2\n" + + _PKG_PW + + " private:\n url: https://host/repo\n password: ${pkg_pw}\n", + "secrets.yaml": "pw: other\n", + }, + {}, + "cannot be confirmed", + id="substitution_defined_twice", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n u: !secret u\n p: !secret p\n" + 'x: !lambda \'use("https://${u}:${p}@host", "https://bob:hunter2@other");\'\n', + "secrets.yaml": "u: bob\np: pw\n", + }, + {}, + "URL with credentials", + id="second_url_in_a_scalar", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pkg_password: hunter2\n" + "packages:\n private:\n url: https://host/repo\n password: ${pkg_password}\n" + }, + {}, + "cannot be confirmed", + id="literal_substitution", + ), + pytest.param( + { + "entry.yaml": "packages:\n private:\n url: https://host/repo\n" + " password: ${ pkg_password }x\n" + }, + {}, + "cannot be confirmed", + id="expression", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n git_host: host\n" + "packages:\n - url: https://user:hunter2@${git_host}/repo\n" + }, + {}, + "URL with credentials", + id="mixed_url", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n user: bob\n pw: hunter2\n" + "packages:\n - url: https://${user}:${pw}@host/repo\n" + }, + {}, + "URL with credentials", + id="literal_url_parts", + ), + pytest.param( + { + "entry.yaml": "wifi: !include {file: wifi.yaml, vars: {pw: other_pw}}\n", + "wifi.yaml": "password: ${pw}\n", + }, + WIFI_PW, + "cannot be confirmed", + id="literal_include_var", + ), + pytest.param( + { + "entry.yaml": _OUTER_VAR, + "outer.yaml": "packages:\n inner: !include inner.yaml\n", + "inner.yaml": "defaults:\n pw: realpw\nmqtt:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + {"mqtt": {"password": SensitiveStr("other_pw")}}, + "cannot be confirmed", + id="nested_include_callee_default_wins", + ), + pytest.param( + { + "entry.yaml": "cfg: !include {file: base.yaml, vars: !secret common_vars}\n", + "base.yaml": "password: ${pw}\n", + "secrets.yaml": "common_vars:\n pw: hunter2\n", + }, + WIFI_PW, + "could not redact", + id="credential_only_inside_a_secret_mapping", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR + "wifi2: !include wifi.yaml\n", + "wifi.yaml": "defaults:\n pw: placeholder\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + "cannot be confirmed", + id="second_include_uses_literal_default", + ), + ], +) +def test_redacted_fails_closed( + project: Path, files: dict[str, str], config: dict, match: str +) -> None: + """An unprovable credential fails the build, and the message never echoes one.""" + with pytest.raises(EsphomeError, match=match) as err: + _redact(project, files, config) + assert not any( + str(node) in str(err.value) + for _, node, is_key in _iter_nodes(config) + if not is_key and isinstance(node, yaml_util.Sensitive) and node + ) + + +@pytest.mark.parametrize( + ("files", "config", "expect"), + [ + pytest.param( + {"wifi.yaml": "password: SUPER_SECRET\n"}, + {"wifi": [{"password": SensitiveStr("SUPER_SECRET")}]}, + {"wifi.yaml": "password: !secret 'api_key'\n"}, + id="inline_value_reuses_the_secret_name", + ), + pytest.param( + { + "wifi.yaml": "password: first_pw\n", + "wifi2.yaml": "password: second_pw\n", + }, + { + "wifi": [ + {"password": SensitiveStr("first_pw")}, + {"password": SensitiveStr("second_pw")}, + ] + }, + { + "wifi.yaml": "password: !secret 'wifi_password'\n", + "wifi2.yaml": "password: !secret 'wifi_password_2'\n", + }, + id="colliding_names_stay_distinct", + ), + pytest.param( + {"wifi.yaml": "ssid: my_ssid\n"}, + {"mqtt": {"password": SensitiveStr("")}}, + {"wifi.yaml": "ssid: my_ssid\n"}, + id="empty_sensitive_value_is_not_swapped", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n" + " actions:\n - action: press\n variables:\n key: string\n" + }, + API_KEY, + {"entry.yaml": "key: string"}, + id="action_variable_named_key", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n" + "binary_sensor: !include buttons.yaml\n", + "buttons.yaml": "- platform: matrix_keypad\n key: A\n", + }, + API_KEY, + {"buttons.yaml": "key: A"}, + id="list_file_key_is_not_the_encryption_key", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n", + "a.yaml": "platform: matrix_keypad\nkey: A\n", + "secrets.yaml": "api_key: SUPER_SECRET\n", + }, + API_KEY, + {"a.yaml": "key: A"}, + id="include_dir_fragment_key_is_not_the_encryption_key", + ), + pytest.param( + { + "entry.yaml": "wifi:\n password: !include password.yaml\n", + "password.yaml": "hunter2\n", + }, + WIFI_PW, + {"entry.yaml": "password: !include 'password.yaml'"}, + id="credential_loaded_by_include", + ), + pytest.param( + { + "entry.yaml": "sensor:\n - platform: xiaomi_lywsd03mmc\n" + " bindkey: !secret device_key\n", + "secrets.yaml": "device_key: 00112233445566778899aabbccddeeff\n", + }, + {"sensor": [{"bindkey": SensitiveStr("00112233445566778899AABBCCDDEEFF")}]}, + {"entry.yaml": "bindkey: !secret 'device_key'"}, + id="lowercase_bind_key_secret", + ), + pytest.param( + { + "entry.yaml": "sensor:\n - platform: xiaomi_lywsd03mmc\n" + " bindkey: 00112233445566778899aabbccddeeff\n", + }, + {"sensor": [{"bindkey": SensitiveStr("00112233445566778899AABBCCDDEEFF")}]}, + {"entry.yaml": "bindkey: !secret 'sensor_bindkey'"}, + id="inline_lowercase_bind_key", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR, + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_include_var", + ), + pytest.param( + { + "entry.yaml": "wifi: !include wifi.yaml\n", + "wifi.yaml": "defaults:\n pw: !secret wifi_password\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_default", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pw: !secret wifi_password\nwifi: !include wifi.yaml\n", + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_substitution", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pw: hunter2\n" + _SECRET_VAR, + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="var_shadows_literal_substitution", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR, + "wifi.yaml": "defaults:\n pw: placeholder\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="var_shadows_literal_default", + ), + pytest.param( + { + "entry.yaml": _OUTER_VAR, + "outer.yaml": "wifi: !include wifi.yaml\n", + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="nested_include_inherits_caller_vars", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pkg_password: !secret pw\n" + "packages:\n private:\n url: https://host/repo\n password: ${pkg_password}\n", + "secrets.yaml": "pw: hunter2\n", + }, + {}, + {}, + id="git_password_from_secret_substitution", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n user: !secret user\n pw: !secret pw\n" + "packages:\n - url: https://${user}:${pw}@host/repo\n", + "secrets.yaml": "user: bob\npw: hunter2\n", + }, + {}, + {}, + id="url_parts_from_secrets", + ), + pytest.param( + { + "entry.yaml": "http_request:\n request_headers: !secret headers\n" + " more: !secret list_headers\n", + "secrets.yaml": "headers:\n Authorization: Bearer hunter2\n" + "list_headers:\n - Bearer hunter2\n", + }, + {}, + { + "entry.yaml": "request_headers: !secret 'headers'\n more: !secret 'list_headers'", + "secrets.yaml": "headers: ''", + }, + id="container_secrets", + ), + pytest.param( + { + "entry.yaml": "http: !include {file: h.yaml, vars: {headers: !secret headers}}\n", + "h.yaml": "request_headers: ${headers}\n", + "secrets.yaml": "headers:\n Authorization: Bearer hunter2\n", + }, + {}, + {"entry.yaml": "headers: !secret 'headers'"}, + id="container_secret_in_include_vars", + ), + pytest.param( + { + "entry.yaml": "cfg: !include {file: base.yaml, vars: !secret common_vars}\n", + "base.yaml": "name: ${name}\n", + "secrets.yaml": "common_vars:\n name: hunter2\n", + }, + {}, + {"entry.yaml": "vars: !secret 'common_vars'"}, + id="secret_vars_mapping", + ), + pytest.param( + { + "entry.yaml": "packages:\n - url: !secret pkg_url\n", + "secrets.yaml": "pkg_url: https://user:hunter2@host/repo\n", + }, + {}, + {"entry.yaml": "url: !secret 'pkg_url'"}, + id="secret_url", + ), + pytest.param( + { + "packages/base.yaml": "wifi: !include ../wifi.yaml\n", + "entry.yaml": "packages: !include_dir_named packages\n", + }, + {}, + { + "entry.yaml": "wifi: !include 'wifi.yaml'", + "packages/base.yaml": "wifi: !include '../wifi.yaml'", + }, + id="dir_include_rebases_nested_include", + ), + pytest.param( + { + "entry.yaml": "!include\n file: base.yaml\n vars:\n name: kitchen\n", + "base.yaml": "esphome:\n name: ${name}\n", + }, + {}, + {"entry.yaml": "!include\nfile: base.yaml\nvars:\n name: kitchen\n"}, + id="document_include_keeps_vars", + ), + pytest.param( + {"wifi.yaml": "ssid: !literal '${ssid}'\n"}, + {}, + {"wifi.yaml": "ssid: !literal '${ssid}'\n"}, + id="literal", + ), + pytest.param( + {"fp.yaml": "password: 0\naccuracy_decimals: 0\n"}, + {"fingerprint_grow": {"password": SensitiveInt(0)}}, + { + "fp.yaml": "password: !secret 'fingerprint_grow_password'\naccuracy_decimals: 0\n" + }, + id="zero_credential_swapped_only_at_its_key", + ), + pytest.param( + { + "fp.yaml": "substitutions:\n fp_password: 12345678\n" + "fingerprint_grow:\n - password: 12345678\n - password: ${fp_password}\n" + "sensor:\n accuracy_decimals: 0\n" + }, + {"fingerprint_grow": [{"password": SensitiveInt(12345678)}] * 2}, + { + "fp.yaml": "fp_password: !secret 'fingerprint_grow_password'\n" + "fingerprint_grow:\n - password: !secret 'fingerprint_grow_password'\n" + " - password: ${fp_password}\nsensor:\n accuracy_decimals: 0\n" + }, + id="int_credential_reused_through_a_substitution", + ), + pytest.param( + { + "fp.yaml": "fingerprint_grow:\n - password: 12345678\n - password: '12345678'\n" + }, + {"fingerprint_grow": [{"password": SensitiveInt(12345678)}] * 2}, + { + "fp.yaml": "fingerprint_grow:\n - password: !secret 'fingerprint_grow_password'\n" + " - password: !secret 'fingerprint_grow_password'\n" + }, + id="int_credential_written_quoted", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n api:\n actions:\n" + " - action: login\n variables:\n" + " password: string\n" + "wifi:\n password: !secret wifi_password\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "password: string"}, + id="inline_package_field_named_password", + ), + pytest.param( + { + "wifi.yaml": "substitutions:\n wifi_password: hunter2\n" + "wifi:\n password: ${wifi_password}\n" + }, + {"wifi": [{"password": SensitiveStr("hunter2")}]}, + {"wifi.yaml": "password: ${wifi_password}"}, + id="substitution_definition", + ), + pytest.param( + { + "entry.yaml": "packages:\n board: !include ${board}.yaml\n" + " off: !include {file: missing.yaml, condition: false}\n" + "logger:\n enabled: true\nwifi:\n password: !secret wifi_password\n" + "sensor:\n - !include s.yaml\n - !include s.yaml\n", + "s.yaml": "platform: template\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "enabled: true", "s.yaml": "platform: template"}, + id="templated_disabled_and_repeated_includes", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n base_pw: !secret wifi_password\n pw: ${base_pw}\n" + "wifi:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "base_pw: !secret 'wifi_password'"}, + id="substitution_alias_of_a_secret", + ), + pytest.param( + { + "entry.yaml": "packages:\n base: !include base.yaml\n", + "base.yaml": "packages:\n loop: !include {file: base.yaml, condition: false}\n" + "wifi:\n password: !secret wifi_password\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"base.yaml": "password: !secret 'wifi_password'"}, + id="disabled_self_include_is_skipped", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n substitutions:\n" + " pw: !secret wifi_password\nwifi:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "pw: !secret 'wifi_password'"}, + id="substitution_defined_in_an_inline_package", + ), + pytest.param( + { + "fp.yaml": "packages:\n base:\n substitutions:\n" + " fp_password: 12345678\n" + "fingerprint_grow:\n password: ${fp_password}\n" + }, + {"fingerprint_grow": {"password": SensitiveInt(12345678)}}, + {"fp.yaml": "fp_password: !secret 'fingerprint_grow_password'"}, + id="int_substitution_defined_in_an_inline_package", + ), + pytest.param( + { + "wifi.yaml": "button:\n - on_press:\n wifi.configure:\n" + " ssid: !lambda 'return id(ssid_text).state;'\n" + " password: !lambda 'return id(pw_text).state;'\n" + }, + { + "button": [ + { + "on_press": [ + { + "then": [ + { + "wifi.configure": { + key: add_class_to_obj( + Lambda(f"return id({key}_text).state;"), + yaml_util.Sensitive, + ) + for key in ("ssid", "password") + } + } + ] + } + ] + } + ] + }, + { + "wifi.yaml": "password: !lambda |-\n return id(pw_text).state;\n" + }, + id="lambda_reading_an_entity_is_kept", + ), + ], +) +def test_redacted_hides_credentials( + project: Path, files: dict[str, str], config: dict, expect: dict[str, str] +) -> None: + """The redacted files contain the expected text and never `hunter2`.""" + out = _redact(project, files, config) + assert b"hunter2" not in b"".join(out.values()) + for rel, text in expect.items(): + assert text in out[rel].decode() + + +@pytest.mark.parametrize("int_first", [True, False]) +def test_redacted_shared_text_keeps_substring_scan( + project: Path, int_first: bool +) -> None: + """A text that is an integer credential in one place and a string in another + is still scanned as a substring, whichever is collected first.""" + thread = ("openthread", {"network_key": SensitiveInt(12345678)}) + mqtt = ("mqtt", {"password": SensitiveStr("12345678")}) + config = dict([thread, mqtt] if int_first else [mqtt, thread]) + files = { + "node.yaml": "network_key: 12345678\npassword: '12345678'\nname: node12345678\n" + } + with pytest.raises(EsphomeError, match="embedded"): + _redact(project, files, config) + + +def test_redacted_symlinked_package_loads_relative_to_its_name( + project: Path, shared: Path +) -> None: + """A directory include inside a symlinked file resolves beside the link.""" + (shared / "pkg.yaml").write_text("sensor: !include_dir_merge_list sensors\n") + (project / "pkg.yaml").symlink_to(shared / "pkg.yaml") + (project / "sensors").mkdir() + (project / "sensors" / "a.yaml").write_text("- platform: template\n name: a\n") + (project / "entry.yaml").write_text("packages:\n p: !include pkg.yaml\n") + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert "platform: template" in files["pkg.yaml"].decode() + + +def test_redacted_symlinked_dir_include_rebases_where_the_loader_reads( + project: Path, shared: Path +) -> None: + """`..` after a symlinked directory goes where the loader reads: through the link + on POSIX, lexically on Windows.""" + (shared / "pkgs").mkdir() + (shared / "pkgs" / "base.yaml").write_text("wifi: !include ../wifi.yaml\n") + (shared / "wifi.yaml").write_bytes(b"ssid: shared\n") + (project / "wifi.yaml").write_bytes(b"ssid: project\n") + (project / "pkgs").symlink_to(shared / "pkgs") + (project / "entry.yaml").write_text("packages: !include_dir_named pkgs\n") + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + # The file itself is captured at its real location, so its own include stays put. + assert "!include '../wifi.yaml'" in files["../shared/pkgs/base.yaml"].decode() + if sys.platform == "win32": + assert "wifi: !include 'wifi.yaml'" in files["entry.yaml"].decode() + assert files["wifi.yaml"] == b"ssid: project\n" + else: + assert "wifi: !include '../shared/wifi.yaml'" in files["entry.yaml"].decode() + assert files["../shared/wifi.yaml"] == b"ssid: shared\n" + + +def test_include_through_a_directory_symlink( + project: Path, shared: Path, tmp_path: Path +) -> None: + """The include is pointed at the captured file and the note names the link, + also when the config directory itself is reached through a symlink.""" + (shared / "wifi.yaml").write_text("ssid: shared\n") + (project / "linked").symlink_to(shared) + (project / "entry.yaml").write_text("wifi: !include linked/wifi.yaml\n") + via = tmp_path / "via" + via.symlink_to(project) + CORE.config_path = via / "entry.yaml" + discovered = discover_user_yaml_files(via / "entry.yaml") + assert _symlink_descriptions(discovered) == ["linked -> ../shared"] + files = _gather_redacted(discovered) + assert "wifi: !include '../shared/wifi.yaml'" in files["entry.yaml"].decode() + assert files["../shared/wifi.yaml"] == b"ssid: shared\n" + + +def test_gather_emits_every_alias_of_one_file(project: Path, shared: Path) -> None: + """Every name linking to one file appears in the envelope, in both modes.""" + (shared / "sensor.yaml").write_bytes(b"platform: template\n") + (project / "a.yaml").symlink_to(shared / "sensor.yaml") + (project / "b.yaml").symlink_to(shared / "sensor.yaml") + (project / "entry.yaml").write_text( + "sensor:\n - !include {file: a.yaml, vars: {n: 1}}\n" + " - !include {file: b.yaml, vars: {n: 2}}\n" + ) + entries, _ = _gather_files(discover_user_yaml_files(project / "entry.yaml")) + assert {rel for rel, _ in entries} == {"entry.yaml", "a.yaml", "b.yaml"} + assert dict(_read_files_verbatim(entries))["b.yaml"] == b"platform: template\n" + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert files["a.yaml"] == files["b.yaml"] == b"platform: template\n" + + +@pytest.mark.parametrize( + ("files", "config", "in_log", "not_in_log"), + [ + pytest.param( + { + "wifi.yaml": "ssid: Kitchen\nname: Kitchen Temperature\n", + "secrets.yaml": "wifi_ssid: Kitchen\n", + }, + {"wifi": [{"ssid": SensitiveStr("Kitchen")}]}, + "not redacted inside name in wifi.yaml", + "", + id="secret_sourced_overlap", + ), + ], +) +def test_redacted_warns_without_failing( + project: Path, + caplog: pytest.LogCaptureFixture, + files: dict[str, str], + config: dict, + in_log: str, + not_in_log: str, +) -> None: + """Collisions with a known value are swapped or kept with a warning, not an error.""" + out = _redact(project, files, config) + assert b"!secret" in out["wifi.yaml"] + assert in_log in caplog.text + assert not not_in_log or not_in_log not in caplog.text + + +def test_uncaptured_note_lists_content() -> None: + rel, content = _uncaptured_note( + ["${board}.yaml"], ["https://github.com/org/repo@main"], ["linked -> ../shared"] + ) + assert rel == UNCAPTURED_NOTE_PATH + text = content.decode() + assert text.startswith("# store_yaml:") + for line in ( + "${board}.yaml", + "https://github.com/org/repo@main", + "linked -> ../shared", + ): + assert f"# {line}" in text + assert _uncaptured_note([], [], []) is None + + +def test_remote_package_descriptions() -> None: + """Sources format as url@ref or url; userinfo is masked and a `!secret` URL named.""" + assert _remote_package_descriptions() == [] + data = packages._get_data() + for url, ref in ( + ("https://github.com/org/repo", "main"), + ("https://github.com/org/other", None), + ("https://user:pw@host/repo", "main"), + ("https://user:pw@host/private", None), + ("https://github.com/org/x", "v1-secret"), + ("https://host/repo?access_token=tok123", None), + ("https://host/repo?k=hunter2abc", None), + ): + data.remote_sources[packages.RemotePackageSource(url, ref)] = None + yaml_util._SECRET_VALUES["https://user:pw@host/private"] = "pkg_url" + yaml_util._SECRET_VALUES["v1-secret"] = "private_ref" + yaml_util._SECRET_VALUES["tok123"] = "git_token" + yaml_util._SECRET_VALUES["hunter2"] = "pw" + yaml_util._SECRET_VALUES["hunter2abc"] = "long_token" + assert _remote_package_descriptions() == [ + "https://github.com/org/repo@main", + "https://github.com/org/other", + "https://***@host/repo@main", + "!secret pkg_url", + "https://github.com/org/x@!secret private_ref", + "https://host/repo?access_token=", + "https://host/repo?k=", + ] + + +def test_secrets_skeleton_quotes_awkward_keys() -> None: + """Keys that YAML would read as other types load back as the same strings.""" + keys = {"true", "123", "a: b", "api_key"} + skeleton = _build_secrets_skeleton(keys).decode() + assert skeleton.startswith(SECRETS_SKELETON_HEADER) + assert "'true': ''" in skeleton + assert yaml.safe_load(skeleton) == dict.fromkeys(keys, "") + assert _build_secrets_skeleton(set()) == SECRETS_SKELETON_HEADER.encode() + + +def test_pack_envelope_roundtrip() -> None: + files = [ + ("entry.yaml", b"esphome:\n name: test\n"), + ("dossiers/élan.yaml", b"ssid: a\n"), + ] + assert unpack_envelope(_pack_envelope(files)) == dict(files) + + +def test_pack_envelope_rejects_duplicate_paths() -> None: + with pytest.raises(EsphomeError, match="duplicate"): + _pack_envelope([("entry.yaml", b"a: 1\n"), ("entry.yaml", b"b: 2\n")]) + + +_ONE = _pack_envelope([("entry.yaml", b"esphome:\n")]) +_ENTRY = struct.pack(" None: + with pytest.raises(EsphomeError, match=match): + unpack_envelope(blob) + + +@pytest.mark.parametrize( + ("api", "ok"), + [ + ({"encryption": {"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="}}, True), + ({}, False), + # Keyless encryption accepts the all-zeros key, so it does not count. + ({"encryption": {}}, False), + ], + ids=["encrypted", "no_encryption", "keyless_encryption"], +) +def test_final_validate_requires_a_key(api: dict, ok: bool) -> None: + config = {CONF_ALLOW_UNENCRYPTED: False} + if ok: + assert _run_final_validate({"api": api}, config) is config + return + with pytest.raises(cv.Invalid, match="requires API encryption"): + _run_final_validate({"api": api}, config) + + +@pytest.mark.parametrize( + ("include_secrets", "expected"), + [ + (False, "without API encryption"), + (True, "verbatim contents of secrets.yaml"), + ], +) +def test_final_validate_allows_unencrypted_with_escape_hatch( + caplog: pytest.LogCaptureFixture, include_secrets: bool, expected: str +) -> None: + config = {CONF_ALLOW_UNENCRYPTED: True, "include_secrets": include_secrets} + assert _run_final_validate({"api": {}}, config) is config + assert expected in caplog.text + + +@pytest.mark.parametrize( + "secrets", + [ + "<<: !include shared.yaml\n", + "wifi_password: hunter2\nextra: !include_dir_named creds\n", + ], + ids=["include", "include_dir"], +) +def test_redacted_rejects_a_secrets_file_that_includes_files( + project: Path, secrets: str +) -> None: + """What a secrets file includes would ship verbatim beside its skeleton.""" + (project / "entry.yaml").write_text("wifi:\n password: !secret wifi_password\n") + (project / "secrets.yaml").write_text(secrets) + (project / "shared.yaml").write_text("wifi_password: hunter2\n") + (project / "creds").mkdir() + with pytest.raises(EsphomeError, match="secrets.yaml uses !include"): + _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + + +@pytest.mark.parametrize( + "secrets", + [ + "# Credentials for !include wifi.yaml\nwifi_password: hunter2\n", + "wifi_password: hunter2\nnote: '!include is not used here'\n", + ], + ids=["comment", "quoted"], +) +def test_redacted_accepts_include_text_that_is_not_a_tag( + project: Path, secrets: str +) -> None: + """Only a real include tag rejects a secrets file, not the word in text.""" + (project / "entry.yaml").write_text("wifi:\n password: !secret wifi_password\n") + (project / "secrets.yaml").write_text(secrets) + CORE.config = WIFI_PW + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert b"hunter2" not in b"".join(files.values()) + + +def test_credential_words_cover_every_sensitive_key() -> None: + """A removed branch is matched by name, so every `cv.sensitive` key must be named.""" + root = Path(store_yaml.__file__).parents[2] + sources = [f.read_text(encoding="utf-8") for f in root.rglob("*.py")] + consts = { + m.group(1): m.group(2) + for text in sources + for m in re.finditer(r'^(CONF_\w+) = "([^"]+)"', text, re.MULTILINE) + } + keys = { + consts.get(m.group(1), m.group(1)) + for text in sources + for m in re.finditer( + r"cv\.(?:Optional|Required|Exclusive|Inclusive)\((CONF_\w+)[^:]*:\s*cv\.(?:sensitive\(|bind_key)", + text, + ) + } + assert {"password", "tlv", "static_pairing_code", "bindkey", "auth_key"} <= keys + # A broadcast SSID is not secret, and a bare `key` also names keypad keys. + missing = keys - {"ssid", "key"} - store_yaml._SENSITIVE_KEYS + assert not missing, f"add {sorted(missing)} to _SENSITIVE_KEYS" + unnamed = { + k + for k in keys - {"ssid"} + if not any(w in k for w in store_yaml._CREDENTIAL_WORDS) + } + assert not unnamed, f"add a word for {sorted(unnamed)} to _CREDENTIAL_WORDS" diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 8d572dba968..c10e7f4efcf 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -45,6 +45,21 @@ from esphome.types import ConfigType }, id="different_manufacturer", ), + pytest.param( + { + "id": "a", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Zigbee", + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Thread", + }, + id="different_interface_description", + ), pytest.param( {"id": "a", "vid": 0x303A, "pid": 0, "product": "ZBT-2"}, {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZWA-2"}, @@ -80,6 +95,16 @@ def test_disjoint_clients_are_accepted(first: ConfigType, second: ConfigType) -> {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, id="identical_filters", ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Zigbee", + }, + id="unfiltered_shadows_interface_filtered", + ), pytest.param( {"id": "a", "vid": 0, "pid": 0}, {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b9caa00c3bc..691c8c32cfa 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1783,6 +1783,23 @@ async def test_templatable_bytes_lambda_payload() -> None: assert "set_code_static" not in text +@pytest.mark.asyncio +async def test_templatable_bytes_lambda_return_type() -> None: + CORE.config = {} + var = MockObj("act", "->") + await templatable_bytes( + Lambda("return {0x01};"), + [], + var.set_code_template, + var.set_code_static, + "payload", + return_type=MockObj("ns::Buffer"), + ) + text = CORE.cpp_main_section + assert "-> ns::Buffer" in text + assert "std::vector" not in text + + @pytest.mark.asyncio async def test_templatable_bytes_rejects_oversized_payload() -> None: CORE.config = {} diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 956ecea9139..efca10097e7 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -986,6 +986,21 @@ def test_static_function_declares_early_and_defines_after_includes() -> None: ] +def test_static_function_accepts_statements() -> None: + call = cg.MockObj("foo")(cg.MockObj("x") >> 2) + cg.static_function( + "esphome__st__fn", ct.void, [(ct.int32, "x")], [cg.statement(call), "return;"] + ) + assert [str(st) for st in CORE.global_statements_after_includes] == [ + "static void esphome__st__fn(int32_t x) {\n foo((x >> 2));\n return;\n}" + ] + + +def test_const_ref_renders_const_reference() -> None: + regs = cg.MockObj("hub", "::").class_("Regs") + assert str(regs.const_ref()) == "const hub::Regs &" + + def test_add_global_after_includes_rejects_non_statements() -> None: with pytest.raises(ValueError, match="must be expression or statement"): CORE.add_global_after_includes("not a statement") diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 5be0d0c7068..8c7d4528e5b 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from esphome import const, cpp_helpers as ch +from esphome import codegen as cg, const, cpp_helpers as ch, cpp_types as ct from esphome.core import CoroPriority, coroutine_with_priority from esphome.cpp_helpers import ComponentSourcePool, register_component_source @@ -403,3 +403,159 @@ async def test_register_component_without_module_has_no_source( assert await ch.register_component(var, {}) is var app_mock.register_component_.assert_called_once_with(var) + + +_REGS = cg.esphome_ns.namespace("hub").class_("Regs") + + +def _status_function(define: str | None = "USE_HUB_STATUS") -> ch.HubFunction: + return ch.HubFunction( + "status", ct.void, [(_REGS.const_ref(), "status")], define=define + ) + + +def _after_includes() -> list[str]: + return [str(st) for st in ch.CORE.global_statements_after_includes] + + +def _main() -> list[str]: + return [str(st) for st in ch.CORE.main_statements] + + +def test_hub_function_one_function_per_hub_in_add_order() -> None: + """Each hub that got items gets its own function, items in the order added.""" + fn = _status_function() + status = fn.param("status") + hub_a, hub_b = cg.MockObj("hub_a", "->"), cg.MockObj("hub_b", "->") + fn.add(hub_a, cg.MockObj("x", "->").publish_state(status[0])) + fn.add(hub_b, cg.MockObj("y", "->").publish_state(status[1])) + fn.add(hub_a, cg.statement(cg.MockObj("z", "->").publish_state(status[2]))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static void esphome__hub_a__status_fn(const hub::Regs & esphome__arg_status) {\n" + " x->publish_state(esphome__arg_status[0]);\n" + " z->publish_state(esphome__arg_status[2]);\n}" + ), + ( + "static void esphome__hub_b__status_fn(const hub::Regs & esphome__arg_status) {\n" + " y->publish_state(esphome__arg_status[1]);\n}" + ), + ] + assert _main() == [ + "hub_a->set_status_publisher(esphome__hub_a__status_fn);", + "hub_b->set_status_publisher(esphome__hub_b__status_fn);", + ] + assert [d.name for d in ch.CORE.defines].count("USE_HUB_STATUS") == 1 + + +def test_hub_function_without_items_emits_nothing() -> None: + _status_function() + ch.CORE.flush_tasks() + assert _after_includes() == [] + assert _main() == [] + assert _define_value("USE_HUB_STATUS") is None + + +def test_hub_functions_on_one_hub_stay_apart() -> None: + """Two functions on one hub, such as press and release, keep their own items.""" + press = ch.HubFunction("press", ct.void, [(ct.uint8, "key")]) + release = ch.HubFunction("release", ct.void, [(ct.uint8, "key")]) + hub = cg.MockObj("keys", "->") + press.add(hub, cg.MockObj("on")(press.param("key"))) + release.add(hub, cg.MockObj("off")(release.param("key"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + "static void esphome__keys__press_fn(uint8_t esphome__arg_key) {\n on(esphome__arg_key);\n}", + "static void esphome__keys__release_fn(uint8_t esphome__arg_key) {\n off(esphome__arg_key);\n}", + ] + assert _main() == [ + "keys->set_press_publisher(esphome__keys__press_fn);", + "keys->set_release_publisher(esphome__keys__release_fn);", + ] + + +def test_hub_function_bool_returns_whether_any_item_was_true() -> None: + fn = ch.HubFunction("claim", ct.bool_, [(ct.uint32, "id")]) + hub = cg.MockObj("reader", "->") + fn.add(hub, cg.MockObj("card_a", "->").process(fn.param("id"))) + fn.add(hub, cg.MockObj("card_b", "->").process(fn.param("id"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static bool esphome__reader__claim_fn(uint32_t esphome__arg_id) {\n" + " bool esphome__found = false;\n" + " (esphome__found |= card_a->process(esphome__arg_id));\n" + " (esphome__found |= card_b->process(esphome__arg_id));\n" + " return esphome__found;\n}" + ) + ] + + +def test_hub_function_rejects_other_return_types() -> None: + with pytest.raises(ValueError, match="void or bool"): + ch.HubFunction("value", ct.int32, []) + + +def test_hub_function_items_cannot_be_hidden_by_their_names() -> None: + """Parameters and the bool result are prefixed, so entity ids like status or any stay visible.""" + fn = ch.HubFunction("claim", ct.bool_, [(ct.bool_, "any")]) + fn.add(cg.MockObj("reader", "->"), cg.MockObj("any", "->").process(fn.param("any"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static bool esphome__reader__claim_fn(bool esphome__arg_any) {\n" + " bool esphome__found = false;\n" + " (esphome__found |= any->process(esphome__arg_any));\n" + " return esphome__found;\n}" + ) + ] + + +def test_hub_function_param_must_exist() -> None: + with pytest.raises(ValueError, match="no parameter 'regs'"): + _status_function().param("regs") + + +def test_hub_function_add_after_emit_raises() -> None: + fn = _status_function() + hub = cg.MockObj("hub_a", "->") + fn.add(hub, cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + with pytest.raises(ValueError, match="after the functions were emitted"): + fn.add(hub, cg.MockObj("x", "->").publish_state(2)) + + +def test_hub_function_without_define() -> None: + fn = _status_function(define=None) + fn.add(cg.MockObj("hub_a", "->"), cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + assert not ch.CORE.defines + assert _main() == ["hub_a->set_status_publisher(esphome__hub_a__status_fn);"] + + +def test_hub_function_emits_again_in_the_next_run() -> None: + """A module-level instance starts empty in every run.""" + fn = _status_function() + fn.add(cg.MockObj("hub_a", "->"), cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + ch.CORE.reset() + fn.add(cg.MockObj("hub_b", "->"), cg.MockObj("y", "->").publish_state(2)) + ch.CORE.flush_tasks() + assert _main() == ["hub_b->set_status_publisher(esphome__hub_b__status_fn);"] + + +def test_hub_function_bool_rejects_statements() -> None: + fn = ch.HubFunction("claim", ct.bool_, [(ct.uint32, "id")]) + with pytest.raises(ValueError, match="takes bool expressions"): + fn.add(cg.MockObj("reader", "->"), cg.statement(cg.MockObj("f")())) + + +def test_hub_functions_with_one_name_on_one_hub_raise() -> None: + hub = cg.MockObj("hub_a", "->") + for fn in (_status_function(), _status_function(define=None)): + fn.add(hub, cg.MockObj("x", "->").publish_state(1)) + with pytest.raises( + ValueError, match="two functions named esphome__hub_a__status_fn" + ): + ch.CORE.flush_tasks()