mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 01:58:39 +00:00
Merge branch 'dev' into rp2040-upload-improvements
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user