Merge branch 'dev' into rp2040-upload-improvements

This commit is contained in:
J. Nick Koston
2026-03-05 09:48:55 -10:00
committed by GitHub
88 changed files with 1587 additions and 849 deletions
+1
View File
@@ -286,6 +286,7 @@ This document provides essential context for AI models interacting with this pro
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome-docs` repository.
* The contribution workflow is the same as for the codebase.
* When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync.
* **Best Practices:**
* **Component Development:** Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests.
+86 -2
View File
@@ -2,7 +2,7 @@
//
// Used by:
// - codeowner-review-request.yml
// - codeowner-approved-label.yml
// - codeowner-approved-label.yml + codeowner-approved-label-update.yml
// - auto-label-pr/detectors.js (detectCodeOwner)
/**
@@ -133,11 +133,95 @@ function loadCodeowners(repoRoot = '.') {
return parseCodeowners(content);
}
/** Possible label actions returned by determineLabelAction. */
const LabelAction = Object.freeze({
ADD: 'add',
REMOVE: 'remove',
NONE: 'none',
});
/**
* Determine what label action is needed for a PR based on codeowner approvals.
*
* Checks changed files against CODEOWNERS patterns, reviews, and current labels
* to decide if the label should be added, removed, or left unchanged.
*
* @param {object} github - octokit instance from actions/github-script
* @param {string} owner - repo owner
* @param {string} repo - repo name
* @param {number} pr_number - pull request number
* @param {Array} codeownersPatterns - from loadCodeowners / fetchCodeowners
* @param {string} labelName - label to manage
* @returns {Promise<LabelAction>}
*/
async function determineLabelAction(github, owner, repo, pr_number, codeownersPatterns, labelName) {
// Get the list of changed files in this PR
const prFiles = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: pr_number }
);
const changedFiles = prFiles.map(file => file.filename);
console.log(`Found ${changedFiles.length} changed files`);
if (changedFiles.length === 0) {
console.log('No changed files found');
return LabelAction.NONE;
}
// Get effective owners using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
const componentCodeowners = effective.users;
console.log(`Component-specific codeowners: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
// Get current labels
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: pr_number
});
const hasLabel = currentLabels.some(label => label.name === labelName);
if (componentCodeowners.size === 0) {
console.log('No component-specific codeowners found');
return hasLabel ? LabelAction.REMOVE : LabelAction.NONE;
}
// Get all reviews and find latest per user
const reviews = await github.paginate(
github.rest.pulls.listReviews,
{ owner, repo, pull_number: pr_number }
);
const latestReviewByUser = new Map();
for (const review of reviews) {
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
latestReviewByUser.set(review.user.login, review);
}
// Check if any component-specific codeowner has an active approval
let hasCodeownerApproval = false;
for (const [login, review] of latestReviewByUser) {
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
console.log(`Codeowner '${login}' has approved`);
hasCodeownerApproval = true;
break;
}
}
if (hasCodeownerApproval && !hasLabel) return LabelAction.ADD;
if (!hasCodeownerApproval && hasLabel) return LabelAction.REMOVE;
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
return LabelAction.NONE;
}
module.exports = {
globToRegex,
parseCodeowners,
fetchCodeowners,
loadCodeowners,
classifyOwners,
getEffectiveOwners
getEffectiveOwners,
LabelAction,
determineLabelAction
};
@@ -0,0 +1,95 @@
# Fallback for fork PRs: phase 1 (codeowner-approved-label.yml) handles
# non-fork PRs directly but can't write labels on fork PRs (read-only token).
# This workflow re-determines the action and applies it if needed.
name: Codeowner Approved Label Update
on:
workflow_run:
workflows: ["Codeowner Approved Label"]
types: [completed]
permissions:
issues: write
pull-requests: read
contents: read
jobs:
update-label:
name: Run
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request_review'
runs-on: ubuntu-latest
steps:
- name: Get PR details
id: pr
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPO: ${{ github.repository }}
run: |
pr_data=$(gh pr list --repo "$REPO" --state open --search "$HEAD_SHA" \
--json number,baseRefName --jq '.[0] // empty')
if [ -z "$pr_data" ]; then
echo "No open PR found for SHA $HEAD_SHA, skipping"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number=$(echo "$pr_data" | jq -r '.number')
base_ref=$(echo "$pr_data" | jq -r '.baseRefName')
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT"
echo "Found PR #$pr_number targeting $base_ref"
- name: Checkout base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ steps.pr.outputs.base_ref }}
sparse-checkout: |
.github/scripts/codeowners.js
CODEOWNERS
- name: Update label
if: steps.pr.outputs.skip != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
with:
script: |
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = parseInt(process.env.PR_NUMBER, 10);
const LABEL_NAME = 'code-owner-approved';
console.log(`Processing PR #${pr_number} for codeowner approval label`);
const codeownersPatterns = loadCodeowners();
const action = await determineLabelAction(
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
);
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (action === LabelAction.REMOVE) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr_number, name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
} catch (error) {
if (error.status !== 404) throw error;
}
} else {
console.log('No label change needed');
}
+36 -117
View File
@@ -1,9 +1,9 @@
# This workflow adds/removes a 'code-owner-approved' label when a
# component-specific codeowner approves (or dismisses) a PR.
# This helps maintainers prioritize PRs that have codeowner sign-off.
# Adds/removes a 'code-owner-approved' label when a component-specific
# codeowner approves (or dismisses) a PR.
#
# Only component-specific codeowners count — the catch-all @esphome/core
# team is excluded so the label reflects domain-expert approval.
# Handles non-fork PRs directly. For fork PRs the GITHUB_TOKEN is read-only,
# so label writes are deferred to codeowner-approved-label-update.yml which
# triggers via workflow_run with write permissions.
name: Codeowner Approved Label
@@ -26,134 +26,53 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/scripts/codeowners.js
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = context.payload.pull_request.number;
const pr_number = parseInt(process.env.PR_NUMBER, 10);
const LABEL_NAME = 'code-owner-approved';
console.log(`Processing PR #${pr_number} for codeowner approval label`);
const codeownersPatterns = loadCodeowners();
const action = await determineLabelAction(
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
);
if (action === LabelAction.NONE) {
console.log('No label change needed');
return;
}
try {
// Get the list of changed files in this PR (with pagination)
const prFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner,
repo,
pull_number: pr_number
}
);
const changedFiles = prFiles.map(file => file.filename);
console.log(`Found ${changedFiles.length} changed files`);
if (changedFiles.length === 0) {
console.log('No changed files found, skipping');
return;
}
// Parse CODEOWNERS from the checked-out base branch
const codeownersPatterns = loadCodeowners();
// Get effective owners using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
// Only keep individual component-specific codeowners (exclude teams)
const componentCodeowners = effective.users;
console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
if (componentCodeowners.size === 0) {
console.log('No component-specific codeowners found for changed files');
// Remove label if present since there are no component codeowners
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`);
} catch (error) {
if (error.status !== 404) {
console.log(`Failed to remove label: ${error.message}`);
}
}
return;
}
// Get all reviews on the PR
const reviews = await github.paginate(
github.rest.pulls.listReviews,
{
owner,
repo,
pull_number: pr_number
}
);
// Get the latest review per user (reviews are returned chronologically)
const latestReviewByUser = new Map();
for (const review of reviews) {
// Skip bot reviews and comment-only reviews
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
latestReviewByUser.set(review.user.login, review);
}
// Check if any component-specific codeowner has an active approval
let hasCodeownerApproval = false;
for (const [login, review] of latestReviewByUser) {
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
console.log(`Codeowner '${login}' has approved`);
hasCodeownerApproval = true;
break;
}
}
// Get current labels to check if label is already present
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: pr_number
});
const hasLabel = currentLabels.some(label => label.name === LABEL_NAME);
if (hasCodeownerApproval && !hasLabel) {
// Add the label
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr_number,
labels: [LABEL_NAME]
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (!hasCodeownerApproval && hasLabel) {
// Remove the label
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
} catch (error) {
if (error.status !== 404) {
console.log(`Failed to remove label: ${error.message}`);
}
}
} else {
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
} else if (action === LabelAction.REMOVE) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr_number, name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
}
} catch (error) {
console.error(error);
core.setFailed(`Failed to process codeowner approval label: ${error.message}`);
if (error.status === 403) {
console.log('Fork PR: deferring label write to phase 2 workflow');
} else if (error.status === 404) {
console.log('Label already removed');
} else {
throw error;
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc
this->current_sensor_->publish_state(NAN);
if (this->speed_sensor_ != nullptr)
this->speed_sensor_->publish_state(NAN);
if (this->speed_sensor_ != nullptr)
if (this->voltage_sensor_ != nullptr)
this->voltage_sensor_->publish_state(NAN);
break;
}
+3 -2
View File
@@ -114,9 +114,10 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
this->helper_ = std::unique_ptr<APIFrameHelper>{new APIPlaintextFrameHelper(std::move(sock))};
}
#elif defined(USE_API_PLAINTEXT)
this->helper_ = std::unique_ptr<APIFrameHelper>{new APIPlaintextFrameHelper(std::move(sock))};
this->helper_ = std::unique_ptr<APIPlaintextFrameHelper>{new APIPlaintextFrameHelper(std::move(sock))};
#elif defined(USE_API_NOISE)
this->helper_ = std::unique_ptr<APIFrameHelper>{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())};
this->helper_ =
std::unique_ptr<APINoiseFrameHelper>{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())};
#else
#error "No frame helper defined"
#endif
+12
View File
@@ -3,6 +3,12 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
#ifdef USE_API_PLAINTEXT
#include "api_frame_helper_plaintext.h"
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_server.h"
@@ -489,7 +495,13 @@ class APIConnection final : public APIServerConnectionBase {
// === Optimal member ordering for 32-bit systems ===
// Group 1: Pointers (4 bytes each on 32-bit)
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
std::unique_ptr<APIFrameHelper> helper_;
#elif defined(USE_API_NOISE)
std::unique_ptr<APINoiseFrameHelper> helper_;
#elif defined(USE_API_PLAINTEXT)
std::unique_ptr<APIPlaintextFrameHelper> helper_;
#endif
APIServer *parent_;
// Group 2: Iterator union (saves ~16 bytes vs separate iterators)
+1 -1
View File
@@ -197,7 +197,7 @@ float ATM90E26Component::get_reactive_power_() {
float ATM90E26Component::get_power_factor_() {
const uint16_t val = this->read16_(ATM90E26_REGISTER_POWERF); // signed
if (val & 0x8000) {
return -(val & 0x7FF) / 1000.0f;
return -(val & 0x7FFF) / 1000.0f;
} else {
return val / 1000.0f;
}
+20 -8
View File
@@ -1,4 +1,5 @@
#include "bedjet_codec.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
@@ -68,6 +69,10 @@ BedjetPacket *BedjetCodec::get_set_runtime_remaining_request(const uint8_t hour,
/** Decodes the extra bytes that were received after being notified with a partial packet. */
void BedjetCodec::decode_extra(const uint8_t *data, uint16_t length) {
if (length < 5) {
ESP_LOGVV(TAG, "Received extra: %d bytes (too short)", length);
return;
}
ESP_LOGVV(TAG, "Received extra: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]);
uint8_t offset = this->last_buffer_size_;
if (offset > 0 && length + offset <= sizeof(BedjetStatusPacket)) {
@@ -90,14 +95,19 @@ void BedjetCodec::decode_extra(const uint8_t *data, uint16_t length) {
* @return `true` if the packet was decoded and represents a "partial" packet; `false` otherwise.
*/
bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) {
if (length < 5) {
ESP_LOGW(TAG, "Received short packet: %d bytes", length);
return false;
}
ESP_LOGV(TAG, "Received: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]);
if (data[1] == PACKET_FORMAT_V3_HOME && data[3] == PACKET_TYPE_STATUS) {
// Clear old buffer
memset(&this->buf_, 0, sizeof(BedjetStatusPacket));
// Copy new data into buffer
memcpy(&this->buf_, data, length);
this->last_buffer_size_ = length;
size_t copy_len = std::min(static_cast<size_t>(length), sizeof(BedjetStatusPacket));
memcpy(&this->buf_, data, copy_len);
this->last_buffer_size_ = copy_len;
// TODO: validate the packet checksum?
if (this->buf_.mode < 7 && this->buf_.target_temp_step >= 38 && this->buf_.target_temp_step <= 86 &&
@@ -113,13 +123,15 @@ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) {
}
} else if (data[1] == PACKET_FORMAT_DEBUG || data[3] == PACKET_TYPE_DEBUG) {
// We don't actually know the packet format for this. Dump packets to log, in case a pattern presents itself.
ESP_LOGVV(TAG,
"received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF; [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, "
"[12]=%d, [-1]=%d",
bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8],
data[9], data[10], data[11], data[12], data[length - 1]);
if (length >= 13) {
ESP_LOGVV(TAG,
"received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF; [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, "
"[12]=%d, [-1]=%d",
bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8],
data[9], data[10], data[11], data[12], data[length - 1]);
}
if (this->has_status()) {
if (this->has_status() && length >= 7) {
this->status_packet_->ambient_temp_step = data[6];
}
} else {
+1
View File
@@ -260,6 +260,7 @@ void DFPlayer::loop() {
ESP_LOGV(TAG, "Playback finished (USB drive)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
break;
case 0x3D:
ESP_LOGV(TAG, "Playback finished (SD card)");
this->is_playing_ = false;
@@ -30,11 +30,9 @@ class Command {
class ReadStateCommand : public Command {
public:
ReadStateCommand() { timeout_ms_ = 500; }
uint8_t execute(DfrobotSen0395Component *parent) override;
uint8_t on_message(std::string &message) override;
protected:
uint32_t timeout_ms_{500};
};
class PowerCommand : public Command {
@@ -99,12 +97,12 @@ class ResetSystemCommand : public Command {
class SaveCfgCommand : public Command {
public:
SaveCfgCommand() { cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; }
SaveCfgCommand() {
cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89";
cmd_duration_ms_ = 3000;
timeout_ms_ = 3500;
}
uint8_t on_message(std::string &message) override;
protected:
uint32_t cmd_duration_ms_{3000};
uint32_t timeout_ms_{3500};
};
class LedModeCommand : public Command {
+2 -2
View File
@@ -110,9 +110,9 @@ uint8_t DS2484OneWireBus::read8() {
}
uint64_t DS2484OneWireBus::read64() {
uint8_t response = 0;
uint64_t response = 0;
for (uint8_t i = 0; i < 8; i++) {
response |= (this->read8() << (i * 8));
response |= (static_cast<uint64_t>(this->read8()) << (i * 8));
}
return response;
}
+1 -1
View File
@@ -72,7 +72,7 @@ void Emc2101Component::setup() {
config |= EMC2101_DAC_BIT;
}
if (this->inverted_) {
config |= EMC2101_POLARITY_BIT;
reg(EMC2101_REGISTER_FAN_CONFIG) |= EMC2101_POLARITY_BIT;
}
if (this->dac_mode_) { // DAC mode configurations
+2 -2
View File
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_OTA
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -86,7 +86,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
socket::ListenSocket *server_{nullptr};
std::unique_ptr<socket::Socket> client_;
std::unique_ptr<ota::OTABackend> backend_;
ota::OTABackendPtr backend_;
uint32_t client_connect_time_{0};
uint16_t port_;
@@ -687,8 +687,6 @@ void EthernetComponent::start_connect_() {
this->status_set_warning();
}
bool EthernetComponent::is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
void EthernetComponent::dump_connect_params_() {
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip);
@@ -76,7 +76,7 @@ class EthernetComponent : public Component {
void dump_config() override;
float get_setup_priority() const override;
void on_powerdown() override { powerdown(); }
bool is_connected();
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
#ifdef USE_ETHERNET_SPI
void set_clk_pin(uint8_t clk_pin);
+1 -1
View File
@@ -34,7 +34,7 @@ AUTO_LOAD = ["sensor"]
CODEOWNERS = ["@coogle", "@ximex"]
gps_ns = cg.esphome_ns.namespace("gps")
GPS = gps_ns.class_("GPS", cg.Component, uart.UARTDevice)
GPS = gps_ns.class_("GPS", cg.PollingComponent, uart.UARTDevice)
GPSListener = gps_ns.class_("GPSListener")
MULTI_CONF = True
@@ -131,7 +131,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps,
buffer_[4] = ms_per_step;
buffer_[5] = (ms_per_step >> 8);
if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_RUN, buffer_, 1) != i2c::ERROR_OK) {
if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_RUN, buffer_, 6) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Run stepper failed!");
this->status_set_warning();
return;
@@ -385,7 +385,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() {
}
haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) {
if (size < sizeof(smartair2_protocol::HaierStatus))
if (size != sizeof(smartair2_protocol::HaierStatus))
return haier_protocol::HandlerError::WRONG_MESSAGE_STRUCTURE;
smartair2_protocol::HaierStatus packet;
memcpy(&packet, packet_buffer, size);
@@ -8,10 +8,6 @@
#include "esphome/components/md5/md5.h"
#include "esphome/components/watchdog/watchdog.h"
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/ota/ota_backend_esp8266.h"
#include "esphome/components/ota/ota_backend_arduino_rp2040.h"
#include "esphome/components/ota/ota_backend_esp_idf.h"
namespace esphome {
namespace http_request {
@@ -69,8 +65,7 @@ void OtaHttpRequestComponent::flash() {
}
}
void OtaHttpRequestComponent::cleanup_(std::unique_ptr<ota::OTABackend> backend,
const std::shared_ptr<HttpContainer> &container) {
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container) {
if (this->update_started_) {
ESP_LOGV(TAG, "Aborting OTA backend");
backend->abort();
@@ -1,6 +1,6 @@
#pragma once
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
@@ -39,7 +39,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
void flash();
protected:
void cleanup_(std::unique_ptr<ota::OTABackend> backend, const std::shared_ptr<HttpContainer> &container);
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container);
uint8_t do_ota_();
std::string get_url_with_auth_(const std::string &url);
bool http_get_md5_();
@@ -136,7 +136,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) {
int timeout = 250; // ms
// Read the data from the UART
while (timeout > 0) {
while (timeout > 0 && buffer_len < static_cast<int>(sizeof(buffer))) {
if (this->available()) {
data = this->read();
if (data > -1) {
@@ -246,7 +246,7 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_
}
void KamstrupKMPComponent::set_sensor_value_(uint16_t command, float value, uint8_t unit_idx) {
const char *unit = UNITS[unit_idx];
const char *unit = unit_idx < sizeof(UNITS) / sizeof(UNITS[0]) ? UNITS[unit_idx] : "";
// Standard sensors
if (command == CMD_HEAT_ENERGY && this->heat_energy_sensor_ != nullptr) {
+4
View File
@@ -108,6 +108,10 @@ bool LwTx::lwtx_free() { return !this->tx_msg_active; }
Send a LightwaveRF message (10 nibbles in bytes)
**/
void LwTx::lwtx_send(const std::vector<uint8_t> &msg) {
if (msg.size() < TX_MSGLEN) {
ESP_LOGW("lightwaverf.sensor", "Message too short: %zu < %u", msg.size(), static_cast<unsigned>(TX_MSGLEN));
return;
}
if (this->tx_translate) {
for (uint8_t i = 0; i < TX_MSGLEN; i++) {
this->tx_buf[i] = TX_NIBBLE[msg[i] & 0xF];
@@ -108,7 +108,7 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
}
// Get temperature of sensor
uint8_t temp_in_c = this->parse_temperature_(mopeka_data);
int8_t temp_in_c = this->parse_temperature_(mopeka_data);
if (this->temperature_ != nullptr) {
this->temperature_->publish_state(temp_in_c);
}
@@ -223,12 +223,12 @@ uint8_t MopekaStdCheck::parse_battery_level_(const mopeka_std_package *message)
return (uint8_t) percent;
}
uint8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) {
int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) {
uint8_t tmp = message->raw_temp;
if (tmp == 0x0) {
return -40;
} else {
return (uint8_t) ((tmp - 25.0f) * 1.776964f);
return static_cast<int8_t>((tmp - 25.0f) * 1.776964f);
}
}
@@ -71,7 +71,7 @@ class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi
float get_lpg_speed_of_sound_(float temperature);
uint8_t parse_battery_level_(const mopeka_std_package *message);
uint8_t parse_temperature_(const mopeka_std_package *message);
int8_t parse_temperature_(const mopeka_std_package *message);
};
} // namespace mopeka_std_check
+1 -1
View File
@@ -80,7 +80,7 @@ void MPU6886Component::setup() {
accel_config &= 0b11100111;
accel_config |= (MPU6886_RANGE_2G << 3);
ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config));
if (!this->write_byte(MPU6886_REGISTER_GYRO_CONFIG, gyro_config)) {
if (!this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config)) {
this->mark_failed();
return;
}
+1 -43
View File
@@ -1,53 +1,11 @@
#include "util.h"
#include "esphome/core/defines.h"
#ifdef USE_NETWORK
#ifdef USE_WIFI
#include "esphome/components/wifi/wifi_component.h"
#endif
#ifdef USE_ETHERNET
#include "esphome/components/ethernet/ethernet_component.h"
#endif
#ifdef USE_OPENTHREAD
#include "esphome/components/openthread/openthread.h"
#endif
#ifdef USE_MODEM
#include "esphome/components/modem/modem_component.h"
#endif
namespace esphome::network {
// The order of the components is important: WiFi should come after any possible main interfaces (it may be used as
// an AP that use a previous interface for NAT).
bool is_connected() {
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected())
return true;
#endif
#ifdef USE_MODEM
if (modem::global_modem_component != nullptr)
return modem::global_modem_component->is_connected();
#endif
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr)
return wifi::global_wifi_component->is_connected();
#endif
#ifdef USE_OPENTHREAD
if (openthread::global_openthread_component != nullptr)
return openthread::global_openthread_component->is_connected();
#endif
#ifdef USE_HOST
return true; // Assume its connected
#endif
return false;
}
// an AP that uses a previous interface for NAT).
bool is_disabled() {
#ifdef USE_MODEM
+44 -1
View File
@@ -2,12 +2,55 @@
#include "esphome/core/defines.h"
#ifdef USE_NETWORK
#include <string>
#include "esphome/core/helpers.h"
#include "ip_address.h"
#ifdef USE_ETHERNET
#include "esphome/components/ethernet/ethernet_component.h"
#endif
#ifdef USE_MODEM
#include "esphome/components/modem/modem_component.h"
#endif
#ifdef USE_WIFI
#include "esphome/components/wifi/wifi_component.h"
#endif
#ifdef USE_OPENTHREAD
#include "esphome/components/openthread/openthread.h"
#endif
namespace esphome::network {
// The order of the components is important: WiFi should come after any possible main interfaces (it may be used as
// an AP that uses a previous interface for NAT).
/// Return whether the node is connected to the network (through wifi, eth, ...)
bool is_connected();
ESPHOME_ALWAYS_INLINE inline bool is_connected() {
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected())
return true;
#endif
#ifdef USE_MODEM
if (modem::global_modem_component != nullptr)
return modem::global_modem_component->is_connected();
#endif
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr)
return wifi::global_wifi_component->is_connected();
#endif
#ifdef USE_OPENTHREAD
if (openthread::global_openthread_component != nullptr)
return openthread::global_openthread_component->is_connected();
#endif
#ifdef USE_HOST
return true; // Assume it's connected
#endif
return false;
}
/// Return whether the network is disabled (only wifi for now)
bool is_disabled();
/// Get the active network hostname
+21
View File
@@ -14,9 +14,12 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_CHANNEL,
CONF_ENABLE_IPV6,
CONF_FRAMEWORK,
CONF_ID,
CONF_LOG_LEVEL,
CONF_OUTPUT_POWER,
CONF_USE_ADDRESS,
PLATFORM_ESP32,
)
from esphome.core import CORE, TimePeriodMilliseconds
import esphome.final_validate as fv
@@ -46,6 +49,15 @@ AUTO_LOAD = ["network"]
CONFLICTS_WITH = ["wifi"]
DEPENDENCIES = ["esp32"]
IDF_TO_OT_LOG_LEVEL = {
"NONE": "NONE",
"ERROR": "CRIT",
"WARN": "WARN",
"INFO": "NOTE",
"DEBUG": "INFO",
"VERBOSE": "DEBG",
}
CONF_DEVICE_TYPES = [
"FTD",
"MTD",
@@ -198,6 +210,15 @@ def _final_validate(_):
"Please set `enable_ipv6: true` in the `network` configuration."
)
if (
(esp32_config := full_config.get(PLATFORM_ESP32)) is not None
and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None
and (log_level := fw_config.get(CONF_LOG_LEVEL)) is not None
):
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_LOG_LEVEL_DYNAMIC", False)
ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level)
add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True)
FINAL_VALIDATE_SCHEMA = _final_validate
+8 -17
View File
@@ -1,9 +1,7 @@
#include "esphome/core/defines.h"
#ifdef USE_OPENTHREAD
#include "openthread.h"
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
#include "esp_openthread.h"
#endif
#include <freertos/portmacro.h>
@@ -48,22 +46,15 @@ void OpenThreadComponent::dump_config() {
}
}
bool OpenThreadComponent::is_connected() {
auto lock = InstanceLock::try_acquire(100);
if (!lock) {
ESP_LOGW(TAG, "Failed to acquire OpenThread lock in is_connected");
return false;
void OpenThreadComponent::on_state_changed_(otChangedFlags flags, void *context) {
if (flags & OT_CHANGED_THREAD_ROLE) {
auto *self = static_cast<OpenThreadComponent *>(context);
// This runs on the OpenThread task thread with the OT lock held,
// so we can safely call otThreadGetDeviceRole directly.
otInstance *instance = esp_openthread_get_instance();
otDeviceRole role = otThreadGetDeviceRole(instance);
self->connected_ = role >= OT_DEVICE_ROLE_CHILD;
}
otInstance *instance = lock->get_instance();
if (instance == nullptr) {
return false;
}
otDeviceRole role = otThreadGetDeviceRole(instance);
// TODO: If we're a leader, check that there is at least 1 known peer
return role >= OT_DEVICE_ROLE_CHILD;
}
// Gets the off-mesh routable address
+4 -1
View File
@@ -8,6 +8,7 @@
#include <openthread/srp_client.h>
#include <openthread/srp_client_buffers.h>
#include <openthread/instance.h>
#include <openthread/thread.h>
#include <optional>
@@ -26,7 +27,7 @@ class OpenThreadComponent : public Component {
bool teardown() override;
float get_setup_priority() const override { return setup_priority::WIFI; }
bool is_connected();
bool is_connected() const { return this->connected_; }
network::IPAddresses get_ip_addresses();
std::optional<otIp6Address> get_omr_address();
void ot_main();
@@ -42,6 +43,7 @@ class OpenThreadComponent : public Component {
protected:
std::optional<otIp6Address> get_omr_address_(InstanceLock &lock);
static void on_state_changed_(otChangedFlags flags, void *context);
std::function<void()> factory_reset_external_callback_;
#if CONFIG_OPENTHREAD_MTD
uint32_t poll_period_{0};
@@ -49,6 +51,7 @@ class OpenThreadComponent : public Component {
std::optional<int8_t> output_power_{};
bool teardown_started_{false};
bool teardown_complete_{false};
bool connected_{false};
private:
// Stores a pointer to a string literal (static storage duration).
@@ -175,6 +175,9 @@ void OpenThreadComponent::ot_main() {
// Pass the existing dataset, or NULL which will use the preprocessor definitions
ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr));
// Register state change callback to update connected_ reactively instead of polling
otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed_, this);
esp_openthread_launch_mainloop();
// Clean up
-13
View File
@@ -49,17 +49,6 @@ enum OTAState {
OTA_ERROR,
};
class OTABackend {
public:
virtual ~OTABackend() = default;
virtual OTAResponseTypes begin(size_t image_size) = 0;
virtual void set_update_md5(const char *md5) = 0;
virtual OTAResponseTypes write(uint8_t *data, size_t len) = 0;
virtual OTAResponseTypes end() = 0;
virtual void abort() = 0;
virtual bool supports_compression() = 0;
};
/** Listener interface for OTA state changes.
*
* Components can implement this interface to receive OTA state updates
@@ -130,7 +119,5 @@ OTAGlobalCallback *get_global_ota_callback();
// - notify_state_deferred_() when in separate task (e.g., web_server OTA)
// This ensures proper listener execution in all contexts.
#endif
std::unique_ptr<ota::OTABackend> make_ota_backend();
} // namespace ota
} // namespace esphome
@@ -12,7 +12,7 @@ namespace ota {
static const char *const TAG = "ota.arduino_libretiny";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoLibreTinyOTABackend>(); }
std::unique_ptr<ArduinoLibreTinyOTABackend> make_ota_backend() { return make_unique<ArduinoLibreTinyOTABackend>(); }
OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) {
// Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA
@@ -7,19 +7,21 @@
namespace esphome {
namespace ota {
class ArduinoLibreTinyOTABackend final : public OTABackend {
class ArduinoLibreTinyOTABackend final {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;
OTAResponseTypes write(uint8_t *data, size_t len) override;
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
OTAResponseTypes begin(size_t image_size);
void set_update_md5(const char *md5);
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
private:
bool md5_set_{false};
};
std::unique_ptr<ArduinoLibreTinyOTABackend> make_ota_backend();
} // namespace ota
} // namespace esphome
@@ -14,7 +14,7 @@ namespace ota {
static const char *const TAG = "ota.arduino_rp2040";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoRP2040OTABackend>(); }
std::unique_ptr<ArduinoRP2040OTABackend> make_ota_backend() { return make_unique<ArduinoRP2040OTABackend>(); }
OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) {
// OTA size of 0 is not currently handled, but
@@ -9,19 +9,21 @@
namespace esphome {
namespace ota {
class ArduinoRP2040OTABackend final : public OTABackend {
class ArduinoRP2040OTABackend final {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;
OTAResponseTypes write(uint8_t *data, size_t len) override;
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
OTAResponseTypes begin(size_t image_size);
void set_update_md5(const char *md5);
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
private:
bool md5_set_{false};
};
std::unique_ptr<ArduinoRP2040OTABackend> make_ota_backend();
} // namespace ota
} // namespace esphome
@@ -48,7 +48,7 @@ namespace esphome::ota {
static const char *const TAG = "ota.esp8266";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ESP8266OTABackend>(); }
std::unique_ptr<ESP8266OTABackend> make_ota_backend() { return make_unique<ESP8266OTABackend>(); }
OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) {
// Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space
+9 -7
View File
@@ -12,15 +12,15 @@ namespace esphome::ota {
/// OTA backend for ESP8266 using native SDK functions.
/// This implementation bypasses the Arduino Updater library to save ~228 bytes of RAM
/// by not having a global Update object in .bss.
class ESP8266OTABackend final : public OTABackend {
class ESP8266OTABackend final {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;
OTAResponseTypes write(uint8_t *data, size_t len) override;
OTAResponseTypes end() override;
void abort() override;
OTAResponseTypes begin(size_t image_size);
void set_update_md5(const char *md5);
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
// Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0)
bool supports_compression() override { return true; }
bool supports_compression() { return true; }
protected:
/// Erase flash sector if current address is at sector boundary
@@ -54,5 +54,7 @@ class ESP8266OTABackend final : public OTABackend {
bool md5_set_{false};
};
std::unique_ptr<ESP8266OTABackend> make_ota_backend();
} // namespace esphome::ota
#endif // USE_ESP8266
@@ -11,7 +11,7 @@
namespace esphome {
namespace ota {
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::IDFOTABackend>(); }
std::unique_ptr<IDFOTABackend> make_ota_backend() { return make_unique<IDFOTABackend>(); }
OTAResponseTypes IDFOTABackend::begin(size_t image_size) {
#ifdef USE_OTA_ROLLBACK
+9 -7
View File
@@ -10,14 +10,14 @@
namespace esphome {
namespace ota {
class IDFOTABackend final : public OTABackend {
class IDFOTABackend final {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;
OTAResponseTypes write(uint8_t *data, size_t len) override;
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
OTAResponseTypes begin(size_t image_size);
void set_update_md5(const char *md5);
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
private:
esp_ota_handle_t update_handle_{0};
@@ -27,6 +27,8 @@ class IDFOTABackend final : public OTABackend {
bool md5_set_{false};
};
std::unique_ptr<IDFOTABackend> make_ota_backend();
} // namespace ota
} // namespace esphome
#endif // USE_ESP32
@@ -0,0 +1,27 @@
#pragma once
#include "ota_backend.h"
#include <memory>
#ifdef USE_ESP8266
#include "ota_backend_esp8266.h"
#elif defined(USE_ESP32)
#include "ota_backend_esp_idf.h"
#elif defined(USE_RP2040)
#include "ota_backend_arduino_rp2040.h"
#elif defined(USE_LIBRETINY)
#include "ota_backend_arduino_libretiny.h"
#elif defined(USE_HOST)
#include "ota_backend_host.h"
#else
// Stub for static analysis when no platform is defined
namespace esphome::ota {
struct StubOTABackend {};
std::unique_ptr<StubOTABackend> make_ota_backend();
} // namespace esphome::ota
#endif
namespace esphome::ota {
using OTABackendPtr = decltype(make_ota_backend());
} // namespace esphome::ota
+1 -1
View File
@@ -8,7 +8,7 @@ namespace esphome::ota {
// Stub implementation - OTA is not supported on host platform.
// All methods return error codes to allow compilation of configs with OTA triggers.
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::HostOTABackend>(); }
std::unique_ptr<HostOTABackend> make_ota_backend() { return make_unique<HostOTABackend>(); }
OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; }
+9 -7
View File
@@ -7,15 +7,17 @@ namespace esphome::ota {
/// Stub OTA backend for host platform - allows compilation but does not implement OTA.
/// All operations return error codes immediately. This enables configurations with
/// OTA triggers to compile for host platform during development.
class HostOTABackend final : public OTABackend {
class HostOTABackend final {
public:
OTAResponseTypes begin(size_t image_size) override;
void set_update_md5(const char *md5) override;
OTAResponseTypes write(uint8_t *data, size_t len) override;
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
OTAResponseTypes begin(size_t image_size);
void set_update_md5(const char *md5);
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
bool supports_compression() { return false; }
};
std::unique_ptr<HostOTABackend> make_ota_backend();
} // namespace esphome::ota
#endif
@@ -3,6 +3,8 @@
#include "esphome/core/helpers.h"
#include "packet_transport.h"
#include <ranges>
#include "esphome/components/xxtea/xxtea.h"
namespace esphome {
@@ -77,7 +79,7 @@ enum DecodeResult {
DECODE_EMPTY,
};
static const size_t MAX_PING_KEYS = 4;
static constexpr size_t MAX_PING_KEYS = 4;
static inline void add(std::vector<uint8_t> &vec, uint32_t data) {
vec.push_back(data & 0xFF);
@@ -168,7 +170,7 @@ class PacketDecoder {
return true;
}
bool decrypt(const uint32_t *key) {
bool decrypt(const uint32_t *key) const {
if (this->get_remaining_size() % 4 != 0) {
return false;
}
@@ -249,9 +251,9 @@ void PacketTransport::init_data_() {
} else {
add(this->data_, DATA_KEY);
}
for (const auto &pkey : this->ping_keys_) {
for (auto &value : this->ping_keys_ | std::views::values) {
add(this->data_, PING_KEY);
add(this->data_, pkey.second);
add(this->data_, value);
}
}
@@ -331,7 +333,7 @@ void PacketTransport::update() {
auto now = millis() / 1000;
if (this->last_key_time_ + this->ping_pong_recyle_time_ < now) {
this->resend_ping_key_ = this->ping_pong_enable_;
ESP_LOGV(TAG, "Ping request, age %u", now - this->last_key_time_);
ESP_LOGV(TAG, "Ping request, age %" PRIu32, now - this->last_key_time_);
this->last_key_time_ = now;
}
for (const auto &provider : this->providers_) {
@@ -339,24 +341,32 @@ void PacketTransport::update() {
if (key_response_age > (this->ping_pong_recyle_time_ * 2u)) {
#ifdef USE_STATUS_SENSOR
if (provider.second.status_sensor != nullptr && provider.second.status_sensor->state) {
ESP_LOGI(TAG, "Ping status for %s timeout at %u with age %u", provider.first.c_str(), now, key_response_age);
ESP_LOGI(TAG, "Ping status for %s timeout at %" PRIu32 " with age %" PRIu32, provider.first.c_str(), now,
key_response_age);
provider.second.status_sensor->publish_state(false);
}
#endif
#ifdef USE_SENSOR
for (auto &sensor : this->remote_sensors_[provider.first]) {
sensor.second->publish_state(NAN);
auto it = this->remote_sensors_.find(provider.first);
if (it != this->remote_sensors_.end()) {
for (auto &val : it->second | std::views::values) {
val->publish_state(NAN);
}
}
#endif
#ifdef USE_BINARY_SENSOR
for (auto &sensor : this->remote_binary_sensors_[provider.first]) {
sensor.second->invalidate_state();
auto bs_it = this->remote_binary_sensors_.find(provider.first);
if (bs_it != this->remote_binary_sensors_.end()) {
for (auto &val : bs_it->second | std::views::values) {
val->invalidate_state();
}
}
#endif
} else {
#ifdef USE_STATUS_SENSOR
if (provider.second.status_sensor != nullptr && !provider.second.status_sensor->state) {
ESP_LOGI(TAG, "Ping status for %s restored at %u with age %u", provider.first.c_str(), now, key_response_age);
ESP_LOGI(TAG, "Ping status for %s restored at %" PRIu32 " with age %" PRIu32, provider.first.c_str(), now,
key_response_age);
provider.second.status_sensor->publish_state(true);
}
#endif
@@ -367,11 +377,16 @@ void PacketTransport::update() {
void PacketTransport::add_key_(const char *name, uint32_t key) {
if (!this->is_encrypted_())
return;
if (this->ping_keys_.count(name) == 0 && this->ping_keys_.size() == MAX_PING_KEYS) {
ESP_LOGW(TAG, "Ping key from %s discarded", name);
return;
auto it = this->ping_keys_.find(name);
if (it == this->ping_keys_.end()) {
if (this->ping_keys_.size() == MAX_PING_KEYS) {
ESP_LOGW(TAG, "Ping key from %s discarded", name);
return;
}
this->ping_keys_.emplace(name, key); // allocates string key once only
} else {
it->second = key; // key string already exists in map, no allocation
}
this->ping_keys_[name] = key;
this->updated_ = true;
ESP_LOGV(TAG, "Ping key from %s now %X", name, (unsigned) key);
}
@@ -431,17 +446,19 @@ void PacketTransport::process_(std::span<const uint8_t> data) {
return;
}
if (this->providers_.count(namebuf) == 0) {
auto it = this->providers_.find(namebuf);
if (it == this->providers_.end()) {
ESP_LOGVV(TAG, "Unknown hostname %s", namebuf);
return;
}
auto &provider = it->second;
ESP_LOGV(TAG, "Found hostname %s", namebuf);
#ifdef USE_SENSOR
auto &sensors = this->remote_sensors_[namebuf];
auto &sensors = this->remote_sensors_.try_emplace(namebuf).first->second;
#endif
#ifdef USE_BINARY_SENSOR
auto &binary_sensors = this->remote_binary_sensors_[namebuf];
auto &binary_sensors = this->remote_binary_sensors_.try_emplace(namebuf).first->second;
#endif
if (!decoder.bump_to(4)) {
@@ -453,7 +470,6 @@ void PacketTransport::process_(std::span<const uint8_t> data) {
return;
}
auto &provider = this->providers_[namebuf];
// if encryption not used with this host, ping check is pointless since it would be easily spoofed.
if (provider.encryption_key.empty())
ping_key_seen = true;
@@ -495,16 +511,19 @@ void PacketTransport::process_(std::span<const uint8_t> data) {
if (decoder.decode(BINARY_SENSOR_KEY, namebuf, sizeof(namebuf), byte) == DECODE_OK) {
ESP_LOGV(TAG, "Got binary sensor %s %d", namebuf, byte);
#ifdef USE_BINARY_SENSOR
if (binary_sensors.count(namebuf) != 0)
binary_sensors[namebuf]->publish_state(byte != 0);
auto bs = binary_sensors.find(namebuf);
if (bs != binary_sensors.end()) {
bs->second->publish_state(byte != 0);
}
#endif
continue;
}
if (decoder.decode(SENSOR_KEY, namebuf, sizeof(namebuf), rdata.u32) == DECODE_OK) {
ESP_LOGV(TAG, "Got sensor %s %f", namebuf, rdata.f32);
#ifdef USE_SENSOR
if (sensors.count(namebuf) != 0)
sensors[namebuf]->publish_state(rdata.f32);
auto sensor_it = sensors.find(namebuf);
if (sensor_it != sensors.end())
sensor_it->second->publish_state(rdata.f32);
#endif
continue;
}
@@ -537,12 +556,18 @@ void PacketTransport::dump_config() {
ESP_LOGCONFIG(TAG, " Remote host: %s", host.first.c_str());
ESP_LOGCONFIG(TAG, " Encrypted: %s", YESNO(!host.second.encryption_key.empty()));
#ifdef USE_SENSOR
for (const auto &sensor : this->remote_sensors_[host.first.c_str()])
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.first.c_str());
auto rs = this->remote_sensors_.find(host.first.c_str());
if (rs != this->remote_sensors_.end()) {
for (const auto &key : rs->second | std::views::keys)
ESP_LOGCONFIG(TAG, " Sensor: %s", key.c_str());
}
#endif
#ifdef USE_BINARY_SENSOR
for (const auto &sensor : this->remote_binary_sensors_[host.first.c_str()])
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", sensor.first.c_str());
auto rbs = this->remote_binary_sensors_.find(host.first.c_str());
if (rbs != this->remote_binary_sensors_.end()) {
for (const auto &key : rbs->second | std::views::keys)
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", key.c_str());
}
#endif
}
}
@@ -24,6 +24,9 @@
namespace esphome {
namespace packet_transport {
// std::less provides allocation-free comparison with const char *
template<typename T> using string_map_t = std::map<std::string, T, std::less<>>;
struct Provider {
std::vector<uint8_t> encryption_key;
const char *name;
@@ -79,15 +82,15 @@ class PacketTransport : public PollingComponent {
#endif
void add_provider(const char *hostname) {
if (this->providers_.count(hostname) == 0) {
if (!this->providers_.contains(hostname)) {
Provider provider{};
provider.name = hostname;
this->providers_[hostname] = provider;
#ifdef USE_SENSOR
this->remote_sensors_[hostname] = std::map<std::string, sensor::Sensor *>();
this->remote_sensors_[hostname] = string_map_t<sensor::Sensor *>();
#endif
#ifdef USE_BINARY_SENSOR
this->remote_binary_sensors_[hostname] = std::map<std::string, binary_sensor::BinarySensor *>();
this->remote_binary_sensors_[hostname] = string_map_t<binary_sensor::BinarySensor *>();
#endif
}
}
@@ -139,23 +142,23 @@ class PacketTransport : public PollingComponent {
#ifdef USE_SENSOR
std::vector<Sensor> sensors_{};
std::map<std::string, std::map<std::string, sensor::Sensor *>> remote_sensors_{};
string_map_t<string_map_t<sensor::Sensor *>> remote_sensors_{};
#endif
#ifdef USE_BINARY_SENSOR
std::vector<BinarySensor> binary_sensors_{};
std::map<std::string, std::map<std::string, binary_sensor::BinarySensor *>> remote_binary_sensors_{};
string_map_t<string_map_t<binary_sensor::BinarySensor *>> remote_binary_sensors_{};
#endif
std::map<std::string, Provider> providers_{};
string_map_t<Provider> providers_{};
std::vector<uint8_t> ping_header_{};
std::vector<uint8_t> header_{};
std::vector<uint8_t> data_{};
std::map<std::string, uint32_t> ping_keys_{};
string_map_t<uint32_t> ping_keys_{};
const char *platform_name_{""};
void add_key_(const char *name, uint32_t key);
void send_ping_pong_request_();
inline bool is_encrypted_() { return !this->encryption_key_.empty(); }
bool is_encrypted_() const { return !this->encryption_key_.empty(); }
};
} // namespace packet_transport
+10 -4
View File
@@ -88,9 +88,10 @@ bool PN532Spi::read_response(uint8_t command, std::vector<uint8_t> &data) {
#endif
ESP_LOGV(TAG, "Header data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), header.data(), header.size()));
if (header[0] != 0x00 && header[1] != 0x00 && header[2] != 0xFF) {
if (header[0] != 0x00 || header[1] != 0x00 || header[2] != 0xFF) {
// invalid packet
ESP_LOGV(TAG, "read data invalid preamble!");
this->disable();
return false;
}
@@ -100,15 +101,20 @@ bool PN532Spi::read_response(uint8_t command, std::vector<uint8_t> &data) {
if (!valid_header) {
ESP_LOGV(TAG, "read data invalid header!");
this->disable();
return false;
}
// full length of message, including command response
// full length of message, including command response (minimum 2: TFI + command response)
uint8_t full_len = header[3];
if (full_len < 2) {
ESP_LOGV(TAG, "read data has no payload");
this->disable();
return false;
}
// length of data, excluding command response
uint8_t len = full_len - 1;
if (full_len == 0)
len = 0;
ESP_LOGV(TAG, "Reading response of length %d", len);
@@ -175,7 +175,8 @@ void PulseCounterSensor::setup() {
void PulseCounterSensor::set_total_pulses(uint32_t pulses) {
this->current_total_ = pulses;
this->total_sensor_->publish_state(pulses);
if (this->total_sensor_ != nullptr)
this->total_sensor_->publish_state(pulses);
}
void PulseCounterSensor::dump_config() {
+1 -1
View File
@@ -251,7 +251,7 @@ void QMP6988Component::set_power_mode_(uint8_t power_mode) {
void QMP6988Component::write_filter_(QMP6988IIRFilter filter) {
uint8_t data;
data = (filter & 0x03);
data = (filter & QMP6988_CONFIG_REG_FILTER_MSK);
this->write_byte(QMP6988_CONFIG_REG, data);
delay(10);
}
+1
View File
@@ -169,6 +169,7 @@ void RC522::loop() {
default:
ESP_LOGE(TAG, "uid_idx_ invalid, uid_idx_ = %d", uid_idx_);
state_ = STATE_DONE;
return;
}
buffer_[1] = 32;
pcd_transceive_data_(2);
+7 -4
View File
@@ -63,10 +63,13 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP
result.acceleration_x = data[6] == 0xFF && data[7] == 0xFF ? NAN : acceleration_x;
result.acceleration_y = data[8] == 0xFF && data[9] == 0xFF ? NAN : acceleration_y;
result.acceleration_z = data[10] == 0xFF && data[11] == 0xFF ? NAN : acceleration_z;
result.acceleration = result.acceleration_x == NAN || result.acceleration_y == NAN || result.acceleration_z == NAN
? NAN
: sqrtf(acceleration_x * acceleration_x + acceleration_y * acceleration_y +
acceleration_z * acceleration_z);
if ((data[6] != 0xFF || data[7] != 0xFF) && (data[8] != 0xFF || data[9] != 0xFF) &&
(data[10] != 0xFF || data[11] != 0xFF)) {
result.acceleration =
sqrtf(acceleration_x * acceleration_x + acceleration_y * acceleration_y + acceleration_z * acceleration_z);
} else {
result.acceleration = NAN;
}
result.battery_voltage = (power_info >> 5) == 0x7FF ? NAN : battery_voltage;
result.tx_power = (power_info & 0x1F) == 0x1F ? NAN : tx_power;
result.movement_counter = movement_counter;
+2 -1
View File
@@ -307,7 +307,7 @@ bool SCD4XComponent::start_measurement_() {
break;
}
static uint8_t remaining_retries = 3;
uint8_t remaining_retries = 3;
while (remaining_retries) {
if (!this->write_command(measurement_command)) {
ESP_LOGE(TAG, "Error starting measurements");
@@ -316,6 +316,7 @@ bool SCD4XComponent::start_measurement_() {
if (--remaining_retries == 0)
return false;
delay(50); // NOLINT wait 50 ms and try again
continue;
}
this->status_clear_warning();
return true;
+3 -1
View File
@@ -41,7 +41,9 @@ void SGP30Component::setup() {
this->mark_failed();
return;
}
this->serial_number_ = encode_uint24(raw_serial_number[0], raw_serial_number[1], raw_serial_number[2]);
this->serial_number_ = (static_cast<uint64_t>(raw_serial_number[0]) << 32) |
(static_cast<uint64_t>(raw_serial_number[1]) << 16) |
static_cast<uint64_t>(raw_serial_number[2]);
ESP_LOGD(TAG, "Serial number: %" PRIu64, this->serial_number_);
// Featureset identification for future use
+1 -1
View File
@@ -10,7 +10,7 @@ static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0};
static const uint8_t SERIAL_NUMBER_COMMAND = 0x89;
void SHT4XComponent::start_heater_() {
uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]};
uint8_t cmd[] = {this->heater_command_};
ESP_LOGD(TAG, "Heater turning on");
if (this->write(cmd, 1) != i2c::ERROR_OK) {
+3 -2
View File
@@ -196,7 +196,8 @@ void Sim800LComponent::parse_cmd_(std::string message) {
case STATE_CREG_WAIT: {
// Response: "+CREG: 0,1" -- the one there means registered ok
// "+CREG: -,-" means not registered ok
bool registered = message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5');
bool registered =
message.size() > 9 && message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5');
if (registered) {
if (!this->registered_) {
ESP_LOGD(TAG, "Registered OK");
@@ -205,7 +206,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
this->expect_ack_ = true;
} else {
ESP_LOGW(TAG, "Registration Fail");
if (message[7] == '0') { // Network registration is disable, enable it
if (message.size() > 7 && message[7] == '0') { // Network registration is disabled, enable it
send_cmd_("AT+CREG=1");
this->expect_ack_ = true;
this->state_ = STATE_SETUP_CMGF;
+2
View File
@@ -35,6 +35,8 @@ bool SmlFile::setup_node(SmlNode *node) {
// Check if we need additional length bytes
if (overlength) {
if (this->pos_ + 1 >= this->buffer_.size())
return false;
// Shift the current length to the higher nibble
// and add the lower nibble of the next byte to the length
length = (length << 4) + (this->buffer_[this->pos_ + 1] & 0x0f);
@@ -169,7 +169,7 @@ void HOT SSD1322::draw_absolute_pixel_internal(int x, int y, Color color) {
// ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary
color4 = (color4 & SSD1322_COLORMASK) << shift;
// first mask off the nibble we must change...
this->buffer_[pos] &= (~SSD1322_COLORMASK >> shift);
this->buffer_[pos] &= (static_cast<uint8_t>(~SSD1322_COLORMASK) >> shift);
// ...then lay the new nibble back on top. done!
this->buffer_[pos] |= color4;
}
@@ -202,7 +202,7 @@ void HOT SSD1325::draw_absolute_pixel_internal(int x, int y, Color color) {
// ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary
color4 = (color4 & SSD1325_COLORMASK) << shift;
// first mask off the nibble we must change...
this->buffer_[pos] &= (~SSD1325_COLORMASK >> shift);
this->buffer_[pos] &= (static_cast<uint8_t>(~SSD1325_COLORMASK) >> shift);
// ...then lay the new nibble back on top. done!
this->buffer_[pos] |= color4;
}
@@ -145,7 +145,7 @@ void HOT SSD1327::draw_absolute_pixel_internal(int x, int y, Color color) {
// ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary
color4 = (color4 & SSD1327_COLORMASK) << shift;
// first mask off the nibble we must change...
this->buffer_[pos] &= (~SSD1327_COLORMASK >> shift);
this->buffer_[pos] &= (static_cast<uint8_t>(~SSD1327_COLORMASK) >> shift);
// ...then lay the new nibble back on top. done!
this->buffer_[pos] |= color4;
}
+3 -3
View File
@@ -56,11 +56,11 @@ void SX1509Component::loop() {
return;
}
int row, col;
for (row = 0; row < 7; row++) {
for (row = 0; row < 8; row++) {
if (key_data & (1 << row))
break;
}
for (col = 8; col < 15; col++) {
for (col = 8; col < 16; col++) {
if (key_data & (1 << col))
break;
}
@@ -229,7 +229,7 @@ void SX1509Component::setup_keypad_() {
this->read_byte_16(REG_DIR_B, &this->ddr_mask_);
for (int i = 0; i < this->rows_; i++)
this->ddr_mask_ &= ~(1 << i);
for (int i = 8; i < (this->cols_ * 2); i++)
for (int i = 8; i < (8 + this->cols_); i++)
this->ddr_mask_ |= (1 << i);
this->write_byte_16(REG_DIR_B, this->ddr_mask_);
@@ -183,6 +183,9 @@ void Tormatic::recompute_position_() {
duration = this->close_duration_;
}
if (duration == 0)
return;
auto delta = direction * diff / duration;
this->position = clamp(this->position + delta, COVER_CLOSED, COVER_OPEN);
+1 -1
View File
@@ -191,7 +191,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) {
arg->tx20_available = true;
return;
}
if (index <= MAX_BUFFER_SIZE) {
if (index < MAX_BUFFER_SIZE) {
arg->buffer[index] = delay;
}
arg->spent_time += delay;
+1 -1
View File
@@ -8,7 +8,7 @@ static const char *const TAG = "ufire_ec";
void UFireECComponent::setup() {
uint8_t version;
if (!this->read_byte(REGISTER_VERSION, &version) && version != 0xFF) {
if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) {
this->mark_failed();
return;
}
+1 -1
View File
@@ -10,7 +10,7 @@ static const char *const TAG = "ufire_ise";
void UFireISEComponent::setup() {
uint8_t version;
if (!this->read_byte(REGISTER_VERSION, &version) && version != 0xFF) {
if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) {
this->mark_failed();
return;
}
@@ -161,7 +161,7 @@ void USBCDCACMInstance::setup() {
// Create a simple, unique task name per interface
char task_name[] = "usb_tx_0";
task_name[sizeof(task_name) - 1] = format_hex_char(static_cast<char>(this->itf_));
task_name[sizeof(task_name) - 2] = format_hex_char(static_cast<char>(this->itf_));
xTaskCreate(usb_tx_task_fn, task_name, stack_size, this, 4, &this->usb_tx_task_handle_);
if (this->usb_tx_task_handle_ == nullptr) {
+3 -1
View File
@@ -1,6 +1,7 @@
#include "vbus.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
namespace esphome {
@@ -106,9 +107,10 @@ void VBus::loop() {
continue;
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)];
size_t log_bytes = std::min(this->buffer_.size(), static_cast<size_t>(VBUS_MAX_LOG_BYTES));
#endif
ESP_LOGV(TAG, "P2 C%04x %04x->%04x: %s", this->command_, this->source_, this->dest_,
format_hex_to(hex_buf, this->buffer_.data(), this->buffer_.size()));
format_hex_to(hex_buf, this->buffer_.data(), log_bytes));
for (auto &listener : this->listeners_)
listener->on_message(this->command_, this->source_, this->dest_, this->buffer_);
this->state_ = 0;
+1 -1
View File
@@ -141,7 +141,7 @@ void VEML7700Component::loop() {
// Datasheet: 2.5 ms before the first measurement is needed, allowing for the correct start of the signal processor
// and oscillator.
// Reality: wait for couple integration times to have first samples captured
this->set_timeout(2 * this->integration_time_, [this]() { this->state_ = State::IDLE; });
this->set_timeout(2 * get_itime_ms(this->integration_time_), [this]() { this->state_ = State::IDLE; });
}
if (this->is_ready()) {
@@ -1,7 +1,7 @@
#include "ota_web_server.h"
#ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
@@ -71,7 +71,7 @@ class OTARequestHandler : public AsyncWebHandler {
bool ota_success_{false};
private:
std::unique_ptr<ota::OTABackend> ota_backend_{nullptr};
ota::OTABackendPtr ota_backend_{nullptr};
};
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
@@ -281,6 +281,10 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
break;
}
case ZWAVE_PARSING_STATE_READ_BL_MENU:
if (this->buffer_index_ >= this->buffer_.size()) {
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
break;
}
this->buffer_[this->buffer_index_++] = byte;
if (!byte) {
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
+4 -4
View File
@@ -81,10 +81,10 @@ class ZWaveProxy : public uart::UARTDevice, public Component {
api::APIConnection *api_connection_{nullptr}; // Current subscribed client
uint32_t setup_time_{0}; // Time when setup() was called
// 8-bit values (grouped together to minimize padding)
uint8_t buffer_index_{0}; // Index for populating the data buffer
uint8_t end_frame_after_{0}; // Payload reception ends after this index
uint8_t last_response_{0}; // Last response type sent
// Small values (grouped by size to minimize padding)
uint16_t buffer_index_{0}; // Index for populating the data buffer
uint16_t end_frame_after_{0}; // Payload reception ends after this index
uint8_t last_response_{0}; // Last response type sent
ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START};
bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode
bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module
+42 -2
View File
@@ -12,6 +12,7 @@ from esphome.__main__ import command_compile, parse_args
from esphome.config import validate_config
from esphome.core import CORE
from esphome.platformio_api import get_idedata
from esphome.yaml_util import load_yaml
# This must coincide with the version in /platformio.ini
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
@@ -44,6 +45,38 @@ def filter_components_without_tests(components: list[str]) -> list[str]:
return filtered_components
# Name of optional per-component YAML config merged into the test build
# before validation so that platform defines (USE_SENSOR, etc.) are generated.
CPP_TEST_CONFIG_FILE = "cpp_test.yaml"
def load_component_test_configs(components: list[str]) -> dict:
"""Load cpp_test.yaml files from test component directories.
These configs are merged into the base test config *before* validation
so that entity registration runs during code generation, which causes
the corresponding USE_* defines to be emitted.
"""
merged: dict = {}
for component in components:
config_file = COMPONENTS_TESTS_DIR / component / CPP_TEST_CONFIG_FILE
if not config_file.exists():
continue
component_config = load_yaml(config_file)
if not component_config:
continue
for key, value in component_config.items():
if (
key in merged
and isinstance(merged[key], list)
and isinstance(value, list)
):
merged[key].extend(value)
else:
merged[key] = value
return merged
def create_test_config(config_name: str, includes: list[str]) -> dict:
"""Create ESPHome test configuration for C++ unit tests.
@@ -115,6 +148,11 @@ def run_tests(selected_components: list[str]) -> int:
config = create_test_config(config_name, includes)
# Merge component-specific test configs (e.g. sensor instances) before
# validation so that entity registration and USE_* defines work.
extra_config = load_component_test_configs(components)
config.update(extra_config)
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
CORE.dashboard = None
@@ -122,8 +160,10 @@ def run_tests(selected_components: list[str]) -> int:
config = validate_config(config, {})
# Add all components and dependencies to the base configuration after validation, so their files
# are added to the build.
config.update({key: {} for key in components_with_dependencies})
# are added to the build. Use setdefault to avoid overwriting entries that were
# already validated (e.g. sensor instances from cpp_test.yaml).
for key in components_with_dependencies:
config.setdefault(key, {})
print(f"Testing components: {', '.join(components)}")
CORE.config = config
@@ -1,3 +1,9 @@
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
log_level: DEBUG
network:
enable_ipv6: true
@@ -0,0 +1,98 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <cstdio>
#include <vector>
#include <gtest/gtest.h>
#include "esphome/components/packet_transport/packet_transport.h"
namespace esphome::packet_transport::testing {
// Protocol constants mirrored from packet_transport.cpp for test packet construction.
static constexpr uint16_t MAGIC_NUMBER = 0x4553;
static constexpr uint16_t MAGIC_PING = 0x5048;
// Concrete testable implementation of PacketTransport.
// Captures sent packets and exposes protected members for verification.
//
// Sensor round-trip tests require USE_SENSOR / USE_BINARY_SENSOR to be defined,
// which happens when 'sensor' and 'binary_sensor' components are in the build.
// Run with --all or include those components to enable the full test suite.
class TestablePacketTransport : public PacketTransport {
public:
using PacketTransport::add_key_;
using PacketTransport::data_;
using PacketTransport::encryption_key_;
using PacketTransport::flush_;
using PacketTransport::header_;
using PacketTransport::increment_code_;
using PacketTransport::init_data_;
using PacketTransport::is_encrypted_;
using PacketTransport::is_provider_;
using PacketTransport::name_;
using PacketTransport::ping_key_;
using PacketTransport::ping_keys_;
using PacketTransport::ping_pong_enable_;
using PacketTransport::ping_pong_recyle_time_;
using PacketTransport::process_;
using PacketTransport::providers_;
using PacketTransport::rolling_code_;
using PacketTransport::rolling_code_enable_;
using PacketTransport::send_data_;
using PacketTransport::updated_;
#ifdef USE_SENSOR
using PacketTransport::add_data_;
using PacketTransport::remote_sensors_;
using PacketTransport::sensors_;
#endif
#ifdef USE_BINARY_SENSOR
using PacketTransport::add_binary_data_;
using PacketTransport::binary_sensors_;
using PacketTransport::remote_binary_sensors_;
#endif
// NOTE: std::vector is used here for test convenience. For production code,
// consider using StaticVector or FixedVector from esphome/core/helpers.h instead.
mutable std::vector<std::vector<uint8_t>> sent_packets;
size_t max_packet_size{512};
bool send_enabled{true};
void send_packet(const std::vector<uint8_t> &buf) const override { this->sent_packets.push_back(buf); }
size_t get_max_packet_size() override { return this->max_packet_size; }
bool should_send() override { return this->send_enabled; }
/// Build the packet header for testing without requiring App or global_preferences.
void init_for_test(const char *name) {
this->name_ = name;
this->header_.clear();
// MAGIC_NUMBER as uint16_t little-endian
this->header_.push_back(MAGIC_NUMBER & 0xFF);
this->header_.push_back((MAGIC_NUMBER >> 8) & 0xFF);
// Length-prefixed hostname
auto len = strlen(name);
this->header_.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len; i++)
this->header_.push_back(name[i]);
// Pad to 4-byte boundary
while (this->header_.size() & 0x3)
this->header_.push_back(0);
}
};
/// Build a MAGIC_PING packet for testing add_key_ / ping-pong flows.
inline std::vector<uint8_t> build_ping_packet(const char *hostname, uint32_t key) {
std::vector<uint8_t> packet;
packet.push_back(MAGIC_PING & 0xFF);
packet.push_back((MAGIC_PING >> 8) & 0xFF);
auto len = strlen(hostname);
packet.push_back(static_cast<uint8_t>(len));
for (size_t i = 0; i < len; i++)
packet.push_back(hostname[i]);
packet.push_back(key & 0xFF);
packet.push_back((key >> 8) & 0xFF);
packet.push_back((key >> 16) & 0xFF);
packet.push_back((key >> 24) & 0xFF);
return packet;
}
} // namespace esphome::packet_transport::testing
@@ -0,0 +1,11 @@
# Extra component configuration required by C++ unit tests.
# Loaded by cpp_unit_test.py and merged into the test build config
# before validation, so that platform defines (USE_SENSOR, etc.) are generated.
sensor:
- platform: template
id: test_cpp_sensor
binary_sensor:
- platform: template
id: test_cpp_binary_sensor
@@ -0,0 +1,445 @@
#include "common.h"
namespace esphome::packet_transport::testing {
// --- Configuration setter tests ---
TEST(PacketTransportTest, SetIsProvider) {
TestablePacketTransport transport;
transport.set_is_provider(true);
EXPECT_TRUE(transport.is_provider_);
}
TEST(PacketTransportTest, SetEncryptionKey) {
TestablePacketTransport transport;
std::vector<uint8_t> key(32, 0xAB);
transport.set_encryption_key(key);
EXPECT_EQ(transport.encryption_key_, key);
EXPECT_TRUE(transport.is_encrypted_());
}
TEST(PacketTransportTest, NoEncryptionByDefault) {
TestablePacketTransport transport;
EXPECT_FALSE(transport.is_encrypted_());
}
TEST(PacketTransportTest, SetRollingCodeEnable) {
TestablePacketTransport transport;
transport.set_rolling_code_enable(true);
EXPECT_TRUE(transport.rolling_code_enable_);
}
TEST(PacketTransportTest, SetPingPongEnable) {
TestablePacketTransport transport;
transport.set_ping_pong_enable(true);
EXPECT_TRUE(transport.ping_pong_enable_);
}
TEST(PacketTransportTest, SetPingPongRecycleTime) {
TestablePacketTransport transport;
transport.set_ping_pong_recycle_time(600);
EXPECT_EQ(transport.ping_pong_recyle_time_, 600u);
}
// --- Provider management ---
TEST(PacketTransportTest, AddProvider) {
TestablePacketTransport transport;
transport.add_provider("host1");
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.providers_.size(), 1u);
}
TEST(PacketTransportTest, AddProviderDuplicate) {
TestablePacketTransport transport;
transport.add_provider("host1");
transport.add_provider("host1");
EXPECT_EQ(transport.providers_.size(), 1u);
}
TEST(PacketTransportTest, SetProviderEncryption) {
TestablePacketTransport transport;
transport.add_provider("host1");
std::vector<uint8_t> key(32, 0xCD);
transport.set_provider_encryption("host1", key);
EXPECT_EQ(transport.providers_["host1"].encryption_key, key);
}
// --- Sensor management (requires USE_SENSOR / USE_BINARY_SENSOR) ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, AddSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_sensor("temp", &s);
ASSERT_EQ(transport.sensors_.size(), 1u);
EXPECT_STREQ(transport.sensors_[0].id, "temp");
EXPECT_EQ(transport.sensors_[0].sensor, &s);
EXPECT_TRUE(transport.sensors_[0].updated);
}
TEST(PacketTransportTest, AddRemoteSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_remote_sensor("host1", "remote_temp", &s);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s);
}
#endif
#ifdef USE_BINARY_SENSOR
TEST(PacketTransportTest, AddBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_binary_sensor("motion", &bs);
ASSERT_EQ(transport.binary_sensors_.size(), 1u);
EXPECT_STREQ(transport.binary_sensors_[0].id, "motion");
EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs);
}
TEST(PacketTransportTest, AddRemoteBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_remote_binary_sensor("host1", "remote_motion", &bs);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs);
}
#endif
// --- Unencrypted round-trip tests (require USE_SENSOR / USE_BINARY_SENSOR) ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, UnencryptedSensorRoundTrip) {
// Encoder
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor local_sensor;
local_sensor.state = 42.5f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
// Decoder
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f; // sentinel
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f);
}
#endif
#ifdef USE_BINARY_SENSOR
TEST(PacketTransportTest, UnencryptedBinarySensorRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
binary_sensor::BinarySensor local_bs;
local_bs.state = true;
encoder.add_binary_sensor("motion", &local_bs);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
binary_sensor::BinarySensor remote_bs;
decoder.add_remote_binary_sensor("sender", "motion", &remote_bs);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_TRUE(remote_bs.state);
}
#endif
#if defined(USE_SENSOR) && defined(USE_BINARY_SENSOR)
TEST(PacketTransportTest, MultipleSensorsRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 10.0f;
s2.state = 20.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
binary_sensor::BinarySensor bs1;
bs1.state = true;
encoder.add_binary_sensor("bs1", &bs1);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
binary_sensor::BinarySensor rbs1;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
decoder.add_remote_binary_sensor("sender", "bs1", &rbs1);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, 10.0f);
EXPECT_FLOAT_EQ(rs2.state, 20.0f);
EXPECT_TRUE(rbs1.state);
}
#endif
// --- Encrypted round-trip ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, EncryptedSensorRoundTrip) {
std::vector<uint8_t> key(32);
for (int i = 0; i < 32; i++)
key[i] = i;
TestablePacketTransport encoder;
encoder.init_for_test("sender");
encoder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 99.9f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
decoder.set_provider_encryption("sender", key);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f);
}
// --- Selective send ---
TEST(PacketTransportTest, SendDataOnlyUpdated) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 1.0f;
s2.state = 2.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
// Mark s1 as not updated, only s2 as updated
encoder.sensors_[0].updated = false;
encoder.sensors_[1].updated = true;
encoder.send_data_(false);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent
EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent
}
#endif
// --- Ping key tests ---
TEST(PacketTransportTest, PingKeyStoredWhenEncrypted) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
transport.set_encryption_key(std::vector<uint8_t>(32, 0xAA));
auto ping = build_ping_packet("requester", 0xDEADBEEF);
transport.process_({ping.data(), ping.size()});
ASSERT_EQ(transport.ping_keys_.size(), 1u);
EXPECT_EQ(transport.ping_keys_["requester"], 0xDEADBEEFu);
}
TEST(PacketTransportTest, PingKeyIgnoredWhenNotEncrypted) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
// No encryption key — add_key_ should be a no-op
auto ping = build_ping_packet("requester", 0xDEADBEEF);
transport.process_({ping.data(), ping.size()});
EXPECT_TRUE(transport.ping_keys_.empty());
}
TEST(PacketTransportTest, PingKeyUpdatedOnRepeat) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
transport.set_encryption_key(std::vector<uint8_t>(32, 0xAA));
auto ping1 = build_ping_packet("host1", 0x1111);
transport.process_({ping1.data(), ping1.size()});
EXPECT_EQ(transport.ping_keys_["host1"], 0x1111u);
// Same host, new key value — should update in place
auto ping2 = build_ping_packet("host1", 0x2222);
transport.process_({ping2.data(), ping2.size()});
EXPECT_EQ(transport.ping_keys_.size(), 1u);
EXPECT_EQ(transport.ping_keys_["host1"], 0x2222u);
}
TEST(PacketTransportTest, PingKeyMaxLimit) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
transport.set_encryption_key(std::vector<uint8_t>(32, 0xAA));
// Fill to MAX_PING_KEYS (4)
for (int i = 0; i < 4; i++) {
char name[16];
snprintf(name, sizeof(name), "host%d", i);
auto ping = build_ping_packet(name, 0x1000 + i);
transport.process_({ping.data(), ping.size()});
}
EXPECT_EQ(transport.ping_keys_.size(), 4u);
// 5th key should be discarded
auto ping = build_ping_packet("host4", 0x9999);
transport.process_({ping.data(), ping.size()});
EXPECT_EQ(transport.ping_keys_.size(), 4u);
EXPECT_FALSE(transport.ping_keys_.contains("host4"));
}
#ifdef USE_SENSOR
TEST(PacketTransportTest, PingKeyIncludedInTransmittedPacket) {
std::vector<uint8_t> key(32, 0xBB);
// Responder: encrypted, owns a sensor
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
// Requester sends a MAGIC_PING that the responder processes
auto ping = build_ping_packet("requester", 0xDEADBEEF);
responder.process_({ping.data(), ping.size()});
ASSERT_EQ(responder.ping_keys_.size(), 1u);
// Responder sends sensor data — ping key should be embedded
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
// The requester decrypts the packet and finds its ping key echoed back,
// which gates the sensor data — if the key is missing, data is blocked.
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f);
}
TEST(PacketTransportTest, MissingPingKeyBlocksSensorData) {
std::vector<uint8_t> key(32, 0xBB);
// Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester with ping-pong enabled expects a key that isn't in the packet
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found
}
#endif
// --- Process error handling ---
TEST(PacketTransportTest, ProcessShortBuffer) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
uint8_t buf[] = {0x53};
// Too short for a magic number - should return safely
transport.process_({buf, 1});
}
TEST(PacketTransportTest, ProcessBadMagic) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
uint8_t buf[] = {0xFF, 0xFF, 0x00, 0x00};
// Wrong magic - should return safely
transport.process_({buf, sizeof(buf)});
}
TEST(PacketTransportTest, ProcessOwnHostname) {
TestablePacketTransport transport;
transport.init_for_test("myself");
// Build a packet from "myself" using a separate encoder
TestablePacketTransport fake_sender;
fake_sender.init_for_test("myself");
fake_sender.send_data_(true);
ASSERT_EQ(fake_sender.sent_packets.size(), 1u);
auto &packet = fake_sender.sent_packets[0];
// Should be silently ignored because hostname matches our own
transport.process_({packet.data(), packet.size()});
}
TEST(PacketTransportTest, ProcessUnknownHostname) {
TestablePacketTransport transport;
transport.init_for_test("receiver");
// No providers registered - "unknown" will not be found
TestablePacketTransport sender;
sender.init_for_test("unknown");
sender.send_data_(true);
ASSERT_EQ(sender.sent_packets.size(), 1u);
auto &packet = sender.sent_packets[0];
// Should return safely without crash
transport.process_({packet.data(), packet.size()});
}
// --- Send disabled ---
TEST(PacketTransportTest, NoSendWhenDisabled) {
TestablePacketTransport transport;
transport.init_for_test("sender");
transport.send_enabled = false;
transport.send_data_(true);
EXPECT_TRUE(transport.sent_packets.empty());
}
} // namespace esphome::packet_transport::testing
@@ -43,6 +43,7 @@ CONF_INJECT_RX = "inject_rx"
CONF_EXPECT_TX = "expect_tx"
CONF_PERIODIC_RX = "periodic_rx"
CONF_ON_TX = "on_tx"
CONF_AUTO_START = "auto_start"
UART_PARITY_OPTIONS = {
"NONE": uart.UARTParityOptions.UART_CONFIG_PARITY_NONE,
@@ -95,6 +96,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(CONF_INJECTIONS, default=[]): cv.ensure_list(INJECTION_SCHEMA),
cv.Optional(CONF_RESPONSES, default=[]): cv.ensure_list(RESPONSE_SCHEMA),
cv.Optional(CONF_PERIODIC_RX, default=[]): cv.ensure_list(PERIODIC_RX_SCHEMA),
cv.Optional(CONF_AUTO_START, default=True): cv.boolean,
cv.Optional(CONF_ON_TX): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MockUartTXTrigger),
@@ -138,6 +140,9 @@ async def to_code(config):
cg.add(var.set_data_bits(config[CONF_DATA_BITS]))
cg.add(var.set_parity(config[CONF_PARITY]))
if not config[CONF_AUTO_START]:
cg.add(var.set_auto_start(False))
for injection in config[CONF_INJECTIONS]:
rx_data = injection[CONF_INJECT_RX]
delay_ms = injection[CONF_DELAY]
@@ -16,17 +16,21 @@ void MockUartComponent::setup() {
}
void MockUartComponent::loop() {
uint32_t now = App.get_loop_component_start_time();
// Initialize scenario start time on first loop() call, after all components have
// finished setup(). This prevents injection delays from being consumed during setup.
if (!this->loop_started_) {
this->loop_started_ = true;
this->scenario_start_ms_ = now;
this->cumulative_delay_ms_ = 0;
ESP_LOGD(TAG, "Scenario started at %u ms", now);
if (this->auto_start_) {
this->start_scenario();
} else {
ESP_LOGD(TAG, "Scenario waiting for manual start");
}
}
if (!this->scenario_active_) {
return;
}
uint32_t now = App.get_loop_component_start_time();
// Process at most ONE timed injection per loop iteration.
// This ensures each injection is in a separate loop cycle, giving the consuming
// component (e.g., LD2410) a chance to process each batch independently.
@@ -50,6 +54,19 @@ void MockUartComponent::loop() {
}
}
void MockUartComponent::start_scenario() {
uint32_t now = App.get_loop_component_start_time();
this->scenario_active_ = true;
this->scenario_start_ms_ = now;
this->cumulative_delay_ms_ = 0;
this->injection_index_ = 0;
this->tx_buffer_.clear();
for (auto &periodic : this->periodic_rx_) {
periodic.last_inject_ms = now;
}
ESP_LOGD(TAG, "Scenario started at %u ms", now);
}
void MockUartComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Mock UART Component:\n"
@@ -78,10 +95,12 @@ void MockUartComponent::write_array(const uint8_t *data, size_t len) {
}
#endif
this->try_match_response_();
if (this->scenario_active_) {
this->try_match_response_();
}
// This directly calls a tx_hook (lambda) as an alternative to the simpler match_response mechanism.
if (this->tx_hook_) {
if (this->tx_hook_ && this->scenario_active_) {
std::vector<uint8_t> buf(data, data + len);
this->tx_hook_(buf);
}
@@ -37,6 +37,8 @@ class MockUartComponent : public uart::UARTComponent, public Component {
void add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx);
void add_periodic_rx(const std::vector<uint8_t> &data, uint32_t interval_ms);
void start_scenario();
void set_auto_start(bool auto_start) { this->auto_start_ = auto_start; }
void set_tx_hook(std::function<void(const std::vector<uint8_t> &)> &&cb) { this->tx_hook_ = std::move(cb); }
void inject_to_rx_buffer(const std::vector<uint8_t> &data);
void inject_to_rx_buffer(const uint8_t *data, size_t len);
@@ -55,6 +57,8 @@ class MockUartComponent : public uart::UARTComponent, public Component {
uint32_t scenario_start_ms_{0};
uint32_t cumulative_delay_ms_{0};
bool loop_started_{false};
bool auto_start_{true};
bool scenario_active_{false};
// TX-triggered responses
struct Response {
@@ -20,6 +20,7 @@ uart:
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Phase 1 (t=100ms): Valid LD2410 normal mode data frame - happy path
# The buffer is clean at this point, so this frame should parse correctly.
@@ -143,3 +144,10 @@ binary_sensor:
name: "Has Moving Target"
has_still_target:
name: "Has Still Target"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -19,6 +19,7 @@ uart:
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Phase 1 (t=100ms): Valid LD2410 engineering mode data frame
# Captured from a real Screek Human Presence Sensor 1U with LD2410 firmware 2.4.x
@@ -154,3 +155,10 @@ binary_sensor:
name: "Has Still Target"
out_pin_presence_status:
name: "Out Pin Presence"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -20,6 +20,7 @@ uart:
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Phase 1 (t=100ms): Valid LD2412 normal mode data frame - happy path
# The buffer is clean at this point, so this frame should parse correctly.
@@ -169,3 +170,10 @@ binary_sensor:
name: "Has Still Target"
filters:
- settle: 50ms
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -19,6 +19,7 @@ uart:
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Phase 1 (t=100ms): Valid LD2412 engineering mode data frame
#
@@ -211,3 +212,10 @@ binary_sensor:
name: "Has Still Target"
filters:
- settle: 50ms
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -22,6 +22,7 @@ uart_mock:
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
responses:
- expect_tx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 1 on device 1
@@ -38,3 +39,10 @@ sensor:
name: "basic_register"
address: 0x03
register_type: holding
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(virtual_uart_dev).start_scenario();'
@@ -22,6 +22,7 @@ uart_mock:
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
on_tx:
- then:
@@ -52,3 +53,10 @@ sensor:
phase_a:
voltage:
name: sdm_voltage
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(virtual_uart_dev).start_scenario();'
+95 -1
View File
@@ -3,10 +3,17 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import logging
from typing import TypeVar
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
from aioesphomeapi import (
BinarySensorState,
ButtonInfo,
EntityInfo,
EntityState,
SensorState,
)
_LOGGER = logging.getLogger(__name__)
@@ -234,3 +241,90 @@ class InitialStateHelper:
asyncio.TimeoutError: If initial states aren't received within timeout
"""
await asyncio.wait_for(self._initial_states_received, timeout=timeout)
class SensorStateCollector:
"""Collects sensor and binary sensor state updates and provides wait helpers.
Usage:
collector = SensorStateCollector(
sensor_names=["moving_distance", "still_distance"],
binary_sensor_names=["has_target"],
)
# Use collector.on_state as the callback (or wrap it)
client.subscribe_states(helper.on_state_wrapper(collector.on_state))
# Wait for all sensors to have at least one value
await collector.wait_for_all(timeout=3.0)
# Access collected states
assert collector.sensor_states["moving_distance"][0] == approx(100.0)
"""
def __init__(
self,
sensor_names: list[str],
binary_sensor_names: list[str] | None = None,
entities: list[EntityInfo] | None = None,
) -> None:
self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names}
self.binary_states: dict[str, list[bool]] = {
name: [] for name in (binary_sensor_names or [])
}
self._key_to_sensor: dict[int, str] = {}
self._waiters: list[tuple[Callable[[], bool], asyncio.Future[bool]]] = []
if entities is not None:
self.build_key_mapping(entities)
def build_key_mapping(self, entities: list[EntityInfo]) -> None:
"""Build key-to-name mapping from entities. Sorted by descending length."""
all_names = list(self.sensor_states.keys()) + list(self.binary_states.keys())
all_names.sort(key=len, reverse=True)
self._key_to_sensor = build_key_to_entity_mapping(entities, all_names)
def on_state(self, state: EntityState) -> None:
"""Process a state update."""
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = self._key_to_sensor.get(state.key)
if sensor_name and sensor_name in self.sensor_states:
self.sensor_states[sensor_name].append(state.state)
self._check_waiters()
elif isinstance(state, BinarySensorState):
sensor_name = self._key_to_sensor.get(state.key)
if sensor_name and sensor_name in self.binary_states:
self.binary_states[sensor_name].append(state.state)
self._check_waiters()
def _check_waiters(self) -> None:
"""Check all pending waiters and resolve any whose condition is met."""
for condition, future in self._waiters:
if not future.done() and condition():
future.set_result(True)
def _all_have_values(self) -> bool:
"""Check if all sensor and binary sensor lists have at least one value."""
return all(len(v) >= 1 for v in self.sensor_states.values()) and all(
len(v) >= 1 for v in self.binary_states.values()
)
async def wait_for_all(self, timeout: float = 3.0) -> None:
"""Wait until all sensors and binary sensors have at least one value."""
if self._all_have_values():
return
future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
self._waiters.append((self._all_have_values, future))
await asyncio.wait_for(future, timeout=timeout)
def add_waiter(self, condition: Callable[[], bool]) -> asyncio.Future[bool]:
"""Add a custom waiter that resolves when condition returns True.
Returns:
A future that resolves when the condition is met.
"""
future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
if condition():
future.set_result(True)
else:
self._waiters.append((condition, future))
return future
+113 -240
View File
@@ -21,16 +21,10 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
EntityState,
SensorInfo,
SensorState,
)
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -64,100 +58,65 @@ async def test_uart_mock_ld2410(
if "uart_mock" in line and "TX " in line:
tx_log_lines.append(line)
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"moving_distance": [],
"still_distance": [],
"moving_energy": [],
"still_energy": [],
"detection_distance": [],
}
binary_states: dict[str, list[bool]] = {
"has_target": [],
"has_moving_target": [],
"has_still_target": [],
}
collector = SensorStateCollector(
sensor_names=[
"moving_distance",
"still_distance",
"moving_energy",
"still_energy",
"detection_distance",
],
binary_sensor_names=[
"has_target",
"has_moving_target",
"has_still_target",
],
)
# Signal when we see recovery frame values
recovery_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
# Check if this is the recovery frame (moving_distance = 50)
if (
sensor_name == "moving_distance"
and state.state == pytest.approx(50.0)
and not recovery_received.done()
):
recovery_received.set_result(True)
elif isinstance(state, BinarySensorState):
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in binary_states:
binary_states[sensor_name].append(state.state)
recovery_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
# Build key mappings for all sensor types
all_names = list(sensor_states.keys()) + list(binary_states.keys())
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
collector.build_key_mapping(entities)
# Set up initial state helper
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Phase 1 values are in the initial states (swallowed by InitialStateHelper).
# Verify them via initial_states dict.
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
assert moving_dist_entity is not None
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
assert initial_moving is not None and isinstance(initial_moving, SensorState)
assert initial_moving.state == pytest.approx(100.0), (
f"Initial moving distance should be 100, got {initial_moving.state}"
)
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
assert still_dist_entity is not None
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
assert initial_still is not None and isinstance(initial_still, SensorState)
assert initial_still.state == pytest.approx(120.0), (
f"Initial still distance should be 120, got {initial_still.state}"
)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
try:
await collector.wait_for_all(timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
moving_energy_entity = find_entity(entities, "moving_energy", SensorInfo)
assert moving_energy_entity is not None
initial_me = initial_state_helper.initial_states.get(moving_energy_entity.key)
assert initial_me is not None and isinstance(initial_me, SensorState)
assert initial_me.state == pytest.approx(50.0), (
f"Initial moving energy should be 50, got {initial_me.state}"
)
still_energy_entity = find_entity(entities, "still_energy", SensorInfo)
assert still_energy_entity is not None
initial_se = initial_state_helper.initial_states.get(still_energy_entity.key)
assert initial_se is not None and isinstance(initial_se, SensorState)
assert initial_se.state == pytest.approx(25.0), (
f"Initial still energy should be 25, got {initial_se.state}"
)
detect_dist_entity = find_entity(entities, "detection_distance", SensorInfo)
assert detect_dist_entity is not None
initial_dd = initial_state_helper.initial_states.get(detect_dist_entity.key)
assert initial_dd is not None and isinstance(initial_dd, SensorState)
assert initial_dd.state == pytest.approx(300.0), (
f"Initial detection distance should be 300, got {initial_dd.state}"
)
# Phase 1 values: moving=100, still=120, energy=50/25, detect=300
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.sensor_states["still_distance"][0] == pytest.approx(120.0)
assert collector.sensor_states["moving_energy"][0] == pytest.approx(50.0)
assert collector.sensor_states["still_energy"][0] == pytest.approx(25.0)
assert collector.sensor_states["detection_distance"][0] == pytest.approx(300.0)
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
@@ -165,12 +124,8 @@ async def test_uart_mock_ld2410(
await asyncio.wait_for(recovery_received, timeout=15.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received sensor states:\n"
f" moving_distance: {sensor_states['moving_distance']}\n"
f" still_distance: {sensor_states['still_distance']}\n"
f" moving_energy: {sensor_states['moving_energy']}\n"
f" still_energy: {sensor_states['still_energy']}\n"
f" detection_distance: {sensor_states['detection_distance']}"
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
# Verify overflow warning was logged
@@ -183,67 +138,36 @@ async def test_uart_mock_ld2410(
# A5 (MAC), AB (distance res), AE (light), 61 (params), FE (config off)
assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock"
tx_data = " ".join(tx_log_lines)
# Verify command frame header appears (FD:FC:FB:FA)
assert "FD:FC:FB:FA" in tx_data, (
"Expected LD2410 command frame header FD:FC:FB:FA in TX log"
)
# Verify command frame footer appears (04:03:02:01)
assert "04:03:02:01" in tx_data, (
"Expected LD2410 command frame footer 04:03:02:01 in TX log"
)
# Recovery frame values (Phase 5, after overflow)
assert len(sensor_states["moving_distance"]) >= 1, (
f"Expected recovery moving_distance, got: {sensor_states['moving_distance']}"
)
# Find the recovery value (moving_distance = 50)
recovery_values = [
v for v in sensor_states["moving_distance"] if v == pytest.approx(50.0)
]
assert len(recovery_values) >= 1, (
f"Expected moving_distance=50 in recovery, got: {sensor_states['moving_distance']}"
)
# Recovery frame: moving=50, still=75, energy=100/80, detect=127
recovery_idx = next(
i
for i, v in enumerate(sensor_states["moving_distance"])
for i, v in enumerate(collector.sensor_states["moving_distance"])
if v == pytest.approx(50.0)
)
assert sensor_states["still_distance"][recovery_idx] == pytest.approx(75.0), (
f"Recovery still distance should be 75, got {sensor_states['still_distance'][recovery_idx]}"
assert collector.sensor_states["still_distance"][recovery_idx] == pytest.approx(
75.0
)
assert sensor_states["moving_energy"][recovery_idx] == pytest.approx(100.0), (
f"Recovery moving energy should be 100, got {sensor_states['moving_energy'][recovery_idx]}"
assert collector.sensor_states["moving_energy"][recovery_idx] == pytest.approx(
100.0
)
assert sensor_states["still_energy"][recovery_idx] == pytest.approx(80.0), (
f"Recovery still energy should be 80, got {sensor_states['still_energy'][recovery_idx]}"
)
assert sensor_states["detection_distance"][recovery_idx] == pytest.approx(
127.0
), (
f"Recovery detection distance should be 127, got {sensor_states['detection_distance'][recovery_idx]}"
assert collector.sensor_states["still_energy"][recovery_idx] == pytest.approx(
80.0
)
assert collector.sensor_states["detection_distance"][
recovery_idx
] == pytest.approx(127.0)
# Verify binary sensors detected targets
# Binary sensors could be in initial states or forwarded states
has_target_entity = find_entity(entities, "has_target", BinarySensorInfo)
assert has_target_entity is not None
initial_ht = initial_state_helper.initial_states.get(has_target_entity.key)
assert initial_ht is not None and isinstance(initial_ht, BinarySensorState)
assert initial_ht.state is True, "Has target should be True"
has_moving_entity = find_entity(entities, "has_moving_target", BinarySensorInfo)
assert has_moving_entity is not None
initial_hm = initial_state_helper.initial_states.get(has_moving_entity.key)
assert initial_hm is not None and isinstance(initial_hm, BinarySensorState)
assert initial_hm.state is True, "Has moving target should be True"
has_still_entity = find_entity(entities, "has_still_target", BinarySensorInfo)
assert has_still_entity is not None
initial_hs = initial_state_helper.initial_states.get(has_still_entity.key)
assert initial_hs is not None and isinstance(initial_hs, BinarySensorState)
assert initial_hs.state is True, "Has still target should be True"
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
assert collector.binary_states["has_moving_target"][0] is True
assert collector.binary_states["has_still_target"][0] is True
@pytest.mark.asyncio
@@ -260,133 +184,82 @@ async def test_uart_mock_ld2410_engineering(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
collector = SensorStateCollector(
sensor_names=[
"moving_distance",
"still_distance",
"moving_energy",
"still_energy",
"detection_distance",
"light",
"gate_0_move_energy",
"gate_1_move_energy",
"gate_2_move_energy",
"gate_0_still_energy",
"gate_1_still_energy",
"gate_2_still_energy",
],
binary_sensor_names=[
"has_target",
"has_moving_target",
"has_still_target",
"out_pin_presence",
],
)
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"moving_distance": [],
"still_distance": [],
"moving_energy": [],
"still_energy": [],
"detection_distance": [],
"light": [],
"gate_0_move_energy": [],
"gate_1_move_energy": [],
"gate_2_move_energy": [],
"gate_0_still_energy": [],
"gate_1_still_energy": [],
"gate_2_still_energy": [],
}
binary_states: dict[str, list[bool]] = {
"has_target": [],
"has_moving_target": [],
"has_still_target": [],
"out_pin_presence": [],
}
# Signal when we see Phase 3 frame (still_distance = 291)
phase3_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
if (
sensor_name == "still_distance"
and state.state == pytest.approx(291.0)
and not phase3_received.done()
):
phase3_received.set_result(True)
elif isinstance(state, BinarySensorState):
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in binary_states:
binary_states[sensor_name].append(state.state)
# Signal when we see Phase 3 frame values
phase3_received = collector.add_waiter(
lambda: pytest.approx(291.0) in collector.sensor_states["still_distance"]
)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
all_names = list(sensor_states.keys()) + list(binary_states.keys())
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Phase 1 initial values (engineering mode frame):
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
try:
await collector.wait_for_all(timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
# Phase 1 values (engineering mode frame):
# moving=30, energy=100, still=30, energy=100, detect=0
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
assert moving_dist_entity is not None
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
assert initial_moving is not None and isinstance(initial_moving, SensorState)
assert initial_moving.state == pytest.approx(30.0), (
f"Initial moving distance should be 30, got {initial_moving.state}"
)
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
assert still_dist_entity is not None
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
assert initial_still is not None and isinstance(initial_still, SensorState)
assert initial_still.state == pytest.approx(30.0), (
f"Initial still distance should be 30, got {initial_still.state}"
)
# Verify engineering mode sensors from initial state
# Gate 0 moving energy = 0x64 = 100
gate0_move_entity = find_entity(entities, "gate_0_move_energy", SensorInfo)
assert gate0_move_entity is not None
initial_g0m = initial_state_helper.initial_states.get(gate0_move_entity.key)
assert initial_g0m is not None and isinstance(initial_g0m, SensorState)
assert initial_g0m.state == pytest.approx(100.0), (
f"Gate 0 move energy should be 100, got {initial_g0m.state}"
)
# Gate 1 moving energy = 0x41 = 65
gate1_move_entity = find_entity(entities, "gate_1_move_energy", SensorInfo)
assert gate1_move_entity is not None
initial_g1m = initial_state_helper.initial_states.get(gate1_move_entity.key)
assert initial_g1m is not None and isinstance(initial_g1m, SensorState)
assert initial_g1m.state == pytest.approx(65.0), (
f"Gate 1 move energy should be 65, got {initial_g1m.state}"
)
# Light sensor = 0x57 = 87
light_entity = find_entity(entities, "light", SensorInfo)
assert light_entity is not None
initial_light = initial_state_helper.initial_states.get(light_entity.key)
assert initial_light is not None and isinstance(initial_light, SensorState)
assert initial_light.state == pytest.approx(87.0), (
f"Light sensor should be 87, got {initial_light.state}"
)
# Out pin presence = 0x01 = True
out_pin_entity = find_entity(entities, "out_pin_presence", BinarySensorInfo)
assert out_pin_entity is not None
initial_out = initial_state_helper.initial_states.get(out_pin_entity.key)
assert initial_out is not None and isinstance(initial_out, BinarySensorState)
assert initial_out.state is True, "Out pin presence should be True"
assert collector.sensor_states["moving_distance"][0] == pytest.approx(30.0)
assert collector.sensor_states["still_distance"][0] == pytest.approx(30.0)
assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0)
assert collector.sensor_states["gate_1_move_energy"][0] == pytest.approx(65.0)
assert collector.sensor_states["light"][0] == pytest.approx(87.0)
assert collector.binary_states["out_pin_presence"][0] is True
# Wait for Phase 3 frame (still_distance = 291cm, multi-byte)
try:
await asyncio.wait_for(phase3_received, timeout=15.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 3 frame. Received sensor states:\n"
f" still_distance: {sensor_states['still_distance']}\n"
f" moving_distance: {sensor_states['moving_distance']}"
f"Timeout waiting for Phase 3 frame. Received:\n"
f" still_distance: {collector.sensor_states['still_distance']}"
)
# Phase 3: still distance = 0x0123 = 291cm (multi-byte distance test)
phase3_still = [
v for v in sensor_states["still_distance"] if v == pytest.approx(291.0)
]
assert len(phase3_still) >= 1, (
f"Expected still_distance=291, got: {sensor_states['still_distance']}"
)
assert pytest.approx(291.0) in collector.sensor_states["still_distance"]
+115 -247
View File
@@ -21,16 +21,10 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
EntityState,
SensorInfo,
SensorState,
)
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -64,104 +58,65 @@ async def test_uart_mock_ld2412(
if "uart_mock" in line and "TX " in line:
tx_log_lines.append(line)
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"moving_distance": [],
"still_distance": [],
"moving_energy": [],
"still_energy": [],
"detection_distance": [],
}
binary_states: dict[str, list[bool]] = {
"has_target": [],
"has_moving_target": [],
"has_still_target": [],
}
collector = SensorStateCollector(
sensor_names=[
"moving_distance",
"still_distance",
"moving_energy",
"still_energy",
"detection_distance",
],
binary_sensor_names=[
"has_target",
"has_moving_target",
"has_still_target",
],
)
# Signal when we see recovery frame values
recovery_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
# Check if this is the recovery frame (moving_distance = 50)
if (
sensor_name == "moving_distance"
and state.state == pytest.approx(50.0)
and not recovery_received.done()
):
recovery_received.set_result(True)
elif isinstance(state, BinarySensorState):
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in binary_states:
binary_states[sensor_name].append(state.state)
recovery_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
# Build key mappings for all sensor types
all_names = list(sensor_states.keys()) + list(binary_states.keys())
# Sort by descending length to avoid substring collisions
# (e.g., "still_energy" matching "gate_0_still_energy")
all_names.sort(key=len, reverse=True)
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
collector.build_key_mapping(entities)
# Set up initial state helper
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Phase 1 values are in the initial states (swallowed by InitialStateHelper).
# Verify them via initial_states dict.
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
assert moving_dist_entity is not None
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
assert initial_moving is not None and isinstance(initial_moving, SensorState)
assert initial_moving.state == pytest.approx(100.0), (
f"Initial moving distance should be 100, got {initial_moving.state}"
)
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
assert still_dist_entity is not None
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
assert initial_still is not None and isinstance(initial_still, SensorState)
assert initial_still.state == pytest.approx(120.0), (
f"Initial still distance should be 120, got {initial_still.state}"
)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
try:
await collector.wait_for_all(timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
moving_energy_entity = find_entity(entities, "moving_energy", SensorInfo)
assert moving_energy_entity is not None
initial_me = initial_state_helper.initial_states.get(moving_energy_entity.key)
assert initial_me is not None and isinstance(initial_me, SensorState)
assert initial_me.state == pytest.approx(50.0), (
f"Initial moving energy should be 50, got {initial_me.state}"
)
still_energy_entity = find_entity(entities, "still_energy", SensorInfo)
assert still_energy_entity is not None
initial_se = initial_state_helper.initial_states.get(still_energy_entity.key)
assert initial_se is not None and isinstance(initial_se, SensorState)
assert initial_se.state == pytest.approx(25.0), (
f"Initial still energy should be 25, got {initial_se.state}"
)
# LD2412 detection_distance = moving_distance when MOVE_BITMASK is set
detect_dist_entity = find_entity(entities, "detection_distance", SensorInfo)
assert detect_dist_entity is not None
initial_dd = initial_state_helper.initial_states.get(detect_dist_entity.key)
assert initial_dd is not None and isinstance(initial_dd, SensorState)
assert initial_dd.state == pytest.approx(100.0), (
f"Initial detection distance should be 100, got {initial_dd.state}"
)
# Phase 1 values: moving=100, still=120, energy=50/25, detect=100
assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0)
assert collector.sensor_states["still_distance"][0] == pytest.approx(120.0)
assert collector.sensor_states["moving_energy"][0] == pytest.approx(50.0)
assert collector.sensor_states["still_energy"][0] == pytest.approx(25.0)
assert collector.sensor_states["detection_distance"][0] == pytest.approx(100.0)
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
@@ -169,12 +124,8 @@ async def test_uart_mock_ld2412(
await asyncio.wait_for(recovery_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received sensor states:\n"
f" moving_distance: {sensor_states['moving_distance']}\n"
f" still_distance: {sensor_states['still_distance']}\n"
f" moving_energy: {sensor_states['moving_energy']}\n"
f" still_energy: {sensor_states['still_energy']}\n"
f" detection_distance: {sensor_states['detection_distance']}"
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
# Verify overflow warning was logged
@@ -185,67 +136,36 @@ async def test_uart_mock_ld2412(
# Verify LD2412 sent setup commands (TX logging)
assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock"
tx_data = " ".join(tx_log_lines)
# Verify command frame header appears (FD:FC:FB:FA)
assert "FD:FC:FB:FA" in tx_data, (
"Expected LD2412 command frame header FD:FC:FB:FA in TX log"
)
# Verify command frame footer appears (04:03:02:01)
assert "04:03:02:01" in tx_data, (
"Expected LD2412 command frame footer 04:03:02:01 in TX log"
)
# Recovery frame values (Phase 5, after overflow)
assert len(sensor_states["moving_distance"]) >= 1, (
f"Expected recovery moving_distance, got: {sensor_states['moving_distance']}"
)
# Find the recovery value (moving_distance = 50)
recovery_values = [
v for v in sensor_states["moving_distance"] if v == pytest.approx(50.0)
]
assert len(recovery_values) >= 1, (
f"Expected moving_distance=50 in recovery, got: {sensor_states['moving_distance']}"
)
# Recovery frame: moving=50, still=75, energy=100/80, detect=50
recovery_idx = next(
i
for i, v in enumerate(sensor_states["moving_distance"])
for i, v in enumerate(collector.sensor_states["moving_distance"])
if v == pytest.approx(50.0)
)
assert sensor_states["still_distance"][recovery_idx] == pytest.approx(75.0), (
f"Recovery still distance should be 75, got {sensor_states['still_distance'][recovery_idx]}"
assert collector.sensor_states["still_distance"][recovery_idx] == pytest.approx(
75.0
)
assert sensor_states["moving_energy"][recovery_idx] == pytest.approx(100.0), (
f"Recovery moving energy should be 100, got {sensor_states['moving_energy'][recovery_idx]}"
assert collector.sensor_states["moving_energy"][recovery_idx] == pytest.approx(
100.0
)
assert sensor_states["still_energy"][recovery_idx] == pytest.approx(80.0), (
f"Recovery still energy should be 80, got {sensor_states['still_energy'][recovery_idx]}"
)
# LD2412 detection_distance = moving_distance when MOVE_BITMASK set
assert sensor_states["detection_distance"][recovery_idx] == pytest.approx(
50.0
), (
f"Recovery detection distance should be 50, got {sensor_states['detection_distance'][recovery_idx]}"
assert collector.sensor_states["still_energy"][recovery_idx] == pytest.approx(
80.0
)
assert collector.sensor_states["detection_distance"][
recovery_idx
] == pytest.approx(50.0)
# Verify binary sensors detected targets
has_target_entity = find_entity(entities, "has_target", BinarySensorInfo)
assert has_target_entity is not None
initial_ht = initial_state_helper.initial_states.get(has_target_entity.key)
assert initial_ht is not None and isinstance(initial_ht, BinarySensorState)
assert initial_ht.state is True, "Has target should be True"
has_moving_entity = find_entity(entities, "has_moving_target", BinarySensorInfo)
assert has_moving_entity is not None
initial_hm = initial_state_helper.initial_states.get(has_moving_entity.key)
assert initial_hm is not None and isinstance(initial_hm, BinarySensorState)
assert initial_hm.state is True, "Has moving target should be True"
has_still_entity = find_entity(entities, "has_still_target", BinarySensorInfo)
assert has_still_entity is not None
initial_hs = initial_state_helper.initial_states.get(has_still_entity.key)
assert initial_hs is not None and isinstance(initial_hs, BinarySensorState)
assert initial_hs.state is True, "Has still target should be True"
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
assert collector.binary_states["has_moving_target"][0] is True
assert collector.binary_states["has_still_target"][0] is True
@pytest.mark.asyncio
@@ -262,120 +182,75 @@ async def test_uart_mock_ld2412_engineering(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"moving_distance": [],
"still_distance": [],
"moving_energy": [],
"still_energy": [],
"detection_distance": [],
"light": [],
"gate_0_move_energy": [],
"gate_1_move_energy": [],
"gate_2_move_energy": [],
"gate_0_still_energy": [],
"gate_1_still_energy": [],
"gate_2_still_energy": [],
}
binary_states: dict[str, list[bool]] = {
"has_target": [],
"has_moving_target": [],
"has_still_target": [],
}
collector = SensorStateCollector(
sensor_names=[
"moving_distance",
"still_distance",
"moving_energy",
"still_energy",
"detection_distance",
"light",
"gate_0_move_energy",
"gate_1_move_energy",
"gate_2_move_energy",
"gate_0_still_energy",
"gate_1_still_energy",
"gate_2_still_energy",
],
binary_sensor_names=[
"has_target",
"has_moving_target",
"has_still_target",
],
)
# Signal when we see Phase 3 frame values
phase3_still_received = loop.create_future()
phase3_detect_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
if (
sensor_name == "still_distance"
and state.state == pytest.approx(291.0)
and not phase3_still_received.done()
):
phase3_still_received.set_result(True)
if (
sensor_name == "detection_distance"
and state.state == pytest.approx(291.0)
and not phase3_detect_received.done()
):
phase3_detect_received.set_result(True)
elif isinstance(state, BinarySensorState):
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in binary_states:
binary_states[sensor_name].append(state.state)
phase3_still_received = collector.add_waiter(
lambda: pytest.approx(291.0) in collector.sensor_states["still_distance"]
)
phase3_detect_received = collector.add_waiter(
lambda: pytest.approx(291.0) in collector.sensor_states["detection_distance"]
)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
all_names = list(sensor_states.keys()) + list(binary_states.keys())
# Sort by descending length to avoid substring collisions
# (e.g., "still_energy" matching "gate_0_still_energy")
all_names.sort(key=len, reverse=True)
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Phase 1 initial values (engineering mode frame):
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
try:
await collector.wait_for_all(timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
# Phase 1 values (engineering mode frame):
# moving=30, energy=100, still=30, energy=100, detect=30
moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo)
assert moving_dist_entity is not None
initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key)
assert initial_moving is not None and isinstance(initial_moving, SensorState)
assert initial_moving.state == pytest.approx(30.0), (
f"Initial moving distance should be 30, got {initial_moving.state}"
)
still_dist_entity = find_entity(entities, "still_distance", SensorInfo)
assert still_dist_entity is not None
initial_still = initial_state_helper.initial_states.get(still_dist_entity.key)
assert initial_still is not None and isinstance(initial_still, SensorState)
assert initial_still.state == pytest.approx(30.0), (
f"Initial still distance should be 30, got {initial_still.state}"
)
# Verify engineering mode sensors from initial state
# Gate 0 moving energy = 0x64 = 100
gate0_move_entity = find_entity(entities, "gate_0_move_energy", SensorInfo)
assert gate0_move_entity is not None
initial_g0m = initial_state_helper.initial_states.get(gate0_move_entity.key)
assert initial_g0m is not None and isinstance(initial_g0m, SensorState)
assert initial_g0m.state == pytest.approx(100.0), (
f"Gate 0 move energy should be 100, got {initial_g0m.state}"
)
# Gate 1 moving energy = 0x41 = 65
gate1_move_entity = find_entity(entities, "gate_1_move_energy", SensorInfo)
assert gate1_move_entity is not None
initial_g1m = initial_state_helper.initial_states.get(gate1_move_entity.key)
assert initial_g1m is not None and isinstance(initial_g1m, SensorState)
assert initial_g1m.state == pytest.approx(65.0), (
f"Gate 1 move energy should be 65, got {initial_g1m.state}"
)
# Light sensor = 0x57 = 87
light_entity = find_entity(entities, "light", SensorInfo)
assert light_entity is not None
initial_light = initial_state_helper.initial_states.get(light_entity.key)
assert initial_light is not None and isinstance(initial_light, SensorState)
assert initial_light.state == pytest.approx(87.0), (
f"Light sensor should be 87, got {initial_light.state}"
)
assert collector.sensor_states["moving_distance"][0] == pytest.approx(30.0)
assert collector.sensor_states["still_distance"][0] == pytest.approx(30.0)
assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0)
assert collector.sensor_states["gate_1_move_energy"][0] == pytest.approx(65.0)
assert collector.sensor_states["light"][0] == pytest.approx(87.0)
# Wait for Phase 3 frame: still_distance = 291cm (multi-byte)
try:
@@ -383,25 +258,18 @@ async def test_uart_mock_ld2412_engineering(
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 3 still_distance. Received:\n"
f" still_distance: {sensor_states['still_distance']}\n"
f" moving_distance: {sensor_states['moving_distance']}"
f" still_distance: {collector.sensor_states['still_distance']}"
)
assert pytest.approx(291.0) in sensor_states["still_distance"], (
f"Expected still_distance=291, got: {sensor_states['still_distance']}"
)
assert pytest.approx(291.0) in collector.sensor_states["still_distance"]
# Wait for Phase 3: detection_distance = 291 (still-only target)
# target_state=0x02 so LD2412 uses still_distance for detection_distance.
# The throttle_with_priority filter may delay this value.
try:
await asyncio.wait_for(phase3_detect_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for detection_distance=291 (still-only target). "
f"Received: {sensor_states['detection_distance']}"
f"Timeout waiting for detection_distance=291. "
f"Received: {collector.sensor_states['detection_distance']}"
)
assert pytest.approx(291.0) in sensor_states["detection_distance"], (
f"Expected detection_distance=291, got: {sensor_states['detection_distance']}"
)
assert pytest.approx(291.0) in collector.sensor_states["detection_distance"]
+12 -2
View File
@@ -12,10 +12,10 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import EntityState, SensorState
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -74,6 +74,11 @@ async def test_uart_mock_modbus(
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for basic register to be updated with successful parse
try:
await asyncio.wait_for(basic_register_changed, timeout=15.0)
@@ -143,6 +148,11 @@ async def test_uart_mock_modbus_timing(
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for voltage to be updated with successful parse
try:
await asyncio.wait_for(voltage_changed, timeout=15.0)