Compare commits

..
Author SHA1 Message Date
J. Nick Koston fef73dbd15 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 17:24:30 -10:00
J. Nick Koston e426e33427 [core] Assert a frame came back before each resync() in the FrameReader tests 2026-10-22 17:24:30 -10:00
J. Nick Koston 0628e4e492 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 16:44:21 -10:00
J. Nick Koston d5c6b01e19 [core] Cap FrameReader headers at 8 bytes to bound the replay depth 2026-10-22 16:44:21 -10:00
J. Nick Koston abbf3a6960 [sm300d2] Wait for the rest of a frame cut off at the end of the buffer 2026-10-22 16:44:12 -10:00
J. Nick Koston a97d4241bd [ds1603l] [ufm01] Read with read_available like the other FrameReader users 2026-10-22 16:33:33 -10:00
J. Nick Koston f1031bc64d [dfrobot_sen0465_uart] Read the answer with one read_available call 2026-10-22 16:31:31 -10:00
J. Nick Koston 57e14c1b54 [core] Wrap the AGENTS.md bus helpers lines at 120 characters 2026-10-22 16:13:15 -10:00
J. Nick Koston 71064efc25 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 16:13:15 -10:00
J. Nick Koston f5ea2d9f01 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:58:37 -10:00
J. Nick Koston 11a228569b [core] State when FrameReader::resync() may be called and list FrameReader in AGENTS.md 2026-10-22 15:58:36 -10:00
J. Nick Koston 5d69784adc [sm300d2] Read the frame with read_available and inline the checksum helper 2026-10-22 15:51:02 -10:00
J. Nick Koston 3b4deefd4c [core] Drop FrameReader's single byte header special case and deduce N in tests 2026-10-22 15:47:52 -10:00
J. Nick Koston 22a0440348 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:47:52 -10:00
J. Nick Koston 46379d8cbc [ufm01] Brace the single log call body 2026-10-22 15:45:41 -10:00
J. Nick Koston f00328f45d Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:39:40 -10:00
J. Nick Koston ddfcc0bc8b [core] Name FrameReader's static header lookup without a trailing underscore 2026-10-22 15:39:36 -10:00
J. Nick Koston 61bd5e2657 [ufm01] Drop the start byte checks FrameReader already made 2026-10-22 15:22:36 -10:00
J. Nick Koston 7a1dc1279d [uart] Read bytes one at a time in the FrameReader users to keep flash down 2026-10-22 15:14:41 -10:00
J. Nick Koston 04165e289f Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:09:52 -10:00
J. Nick Koston 965b26dab6 [core] Realign a broken FrameReader header by replaying its bytes 2026-10-22 15:09:51 -10:00
J. Nick Koston a7e5a2a621 [uart] Collect fixed length frames with FrameReader in five components 2026-10-22 15:08:57 -10:00
J. Nick Koston 8824610f82 [core] Add FrameReader to collect fixed length binary frames 2026-10-22 15:04:08 -10:00
20 changed files with 306 additions and 359 deletions

No files matched your search

+4 -3
View File
@@ -216,9 +216,10 @@ file does, and it is the authority when they disagree. The most useful starting
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; I2C has `update_register`,
`write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`.
`if (!this->status_comm_result(ok)) return;` sets or clears the warning. A child sensor that skips repeated
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
@@ -1,10 +1,7 @@
#include "dfrobot_sen0465_uart.h"
#include <algorithm>
namespace esphome::dfrobot_sen0465_uart {
using dfrobot_sen0465_base::FRAME_HEAD;
using dfrobot_sen0465_base::FRAME_SIZE;
using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID;
@@ -18,30 +15,21 @@ void DFRobotSEN0465UARTComponent::setup() {
}
void DFRobotSEN0465UARTComponent::loop() {
// The tail of a reading the probe sent on its own may precede the answer; start at the next frame head.
uint8_t byte;
while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD)
this->read_byte(&byte);
const size_t available = this->available();
if (available == 0)
return;
const size_t len = std::min<size_t>(available, FRAME_SIZE - this->rx_count_);
if (!this->read_array(this->rx_buffer_ + this->rx_count_, len))
return;
this->rx_count_ += len;
if (this->rx_count_ < FRAME_SIZE)
// The tail of a reading the probe sent on its own may precede the answer; the reader skips to the frame head.
const uint8_t *frame = nullptr;
this->read_available<FRAME_SIZE>([&](uint8_t c) { return (frame = this->reader_.feed(c)) == nullptr; });
if (frame == nullptr)
return;
this->rx_count_ = 0;
this->cancel_timeout(RESPONSE_TIMEOUT_ID);
this->disable_loop();
this->handle_response_(this->rx_buffer_);
this->handle_response_(frame);
}
bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) {
// Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set
this->discard_available();
this->rx_count_ = 0;
this->reader_.reset();
this->write_array(frame, FRAME_SIZE);
return true;
}
@@ -50,7 +38,7 @@ void DFRobotSEN0465UARTComponent::read_response() {
this->enable_loop();
this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() {
this->disable_loop();
this->rx_count_ = 0;
this->reader_.reset();
this->handle_no_response_();
});
}
@@ -2,6 +2,7 @@
#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/frame_reader.h"
namespace esphome::dfrobot_sen0465_uart {
@@ -17,8 +18,7 @@ class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSE
bool write_frame(const uint8_t *frame) override;
void read_response() override;
uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE];
uint8_t rx_count_{0};
FrameReader<dfrobot_sen0465_base::FRAME_SIZE, dfrobot_sen0465_base::FRAME_HEAD> reader_;
};
} // namespace esphome::dfrobot_sen0465_uart
-10
View File
@@ -181,16 +181,6 @@ def get_all_display_metadata() -> dict[str, DisplayMetaData]:
return {id_.id: meta for id_, meta in entries}
def config_has_writer(config: ConfigType) -> bool:
"""Return True when the display config draws anything."""
return (
config.get(CONF_AUTO_CLEAR_ENABLED) is True
or config.get(CONF_PAGES) is not None
or config.get(CONF_LAMBDA) is not None
or config.get(CONF_SHOW_TEST_CARD) is True
)
def get_display_metadata(display_id: ID) -> DisplayMetaData:
"""Get display metadata by ID object
@@ -1,28 +0,0 @@
#include "init_sequence.h"
#include "esphome/core/hal.h"
namespace esphome::display {
InitSequenceStep InitSequenceReader::next(InitSequenceEntry &entry, uint8_t max_count) {
const size_t remaining = this->length_ - this->index_;
if (remaining == 0)
return INIT_SEQUENCE_STEP_END;
const uint8_t *pos = this->sequence_ + this->index_;
if (remaining >= 2) {
entry.value = progmem_read_byte(pos);
entry.count = progmem_read_byte(pos + 1);
entry.args = pos + 2;
if (entry.count == INIT_SEQUENCE_DELAY_FLAG) {
this->index_ += 2;
return INIT_SEQUENCE_STEP_DELAY;
}
if (entry.count <= max_count && remaining - 2 >= entry.count) {
this->index_ += 2 + entry.count;
return INIT_SEQUENCE_STEP_COMMAND;
}
}
this->index_ = this->length_;
return INIT_SEQUENCE_STEP_MALFORMED;
}
} // namespace esphome::display
@@ -1,40 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace esphome::display {
/// Count byte that marks a delay entry: the byte before it is the delay in ms.
static constexpr uint8_t INIT_SEQUENCE_DELAY_FLAG = 0xFF;
/// Unscoped so `while (auto step = reader.next(entry))` stops at END.
enum InitSequenceStep : uint8_t {
INIT_SEQUENCE_STEP_END = 0,
INIT_SEQUENCE_STEP_COMMAND,
INIT_SEQUENCE_STEP_DELAY,
INIT_SEQUENCE_STEP_MALFORMED,
};
/// `value` is the command or the delay in ms; `args` points into the (possibly PROGMEM) sequence.
struct InitSequenceEntry {
const uint8_t *args;
uint8_t value;
uint8_t count;
};
/// Walks a mipi.flatten_sequence table (`cmd, count, args...` or `ms, 0xFF`) with progmem reads; resumable.
class InitSequenceReader {
public:
InitSequenceReader(const uint8_t *sequence, size_t length) : sequence_(sequence), length_(length) {}
/// Fill `entry` with the next entry; a command with more than `max_count` args is MALFORMED.
InitSequenceStep next(InitSequenceEntry &entry, uint8_t max_count = 0xFE);
protected:
const uint8_t *sequence_;
size_t length_;
size_t index_{0};
};
} // namespace esphome::display
+15 -36
View File
@@ -1,7 +1,5 @@
#include "ds1603l.h"
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -10,35 +8,28 @@ namespace esphome::ds1603l {
ESPHOME_LOG_TAG(TAG, "ds1603l.sensor");
void DS1603L::loop() {
// Assemble frames one byte at a time so a stream that starts mid-frame can realign
uint8_t byte;
while (this->available() > 0 && this->read_byte(&byte)) {
if (this->rx_count_ == 0 && byte != HEADER_BYTE) {
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
continue;
this->read_available([this](uint8_t byte) {
const uint8_t *frame = this->reader_.feed(byte);
if (frame == nullptr) {
if (this->reader_.length() == 0) {
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
}
return;
}
this->rx_buffer_[this->rx_count_++] = byte;
if (this->rx_count_ < FRAME_SIZE) {
continue;
}
if (this->parse_data_()) {
this->rx_count_ = 0;
} else {
if (!this->parse_data_(frame)) {
// The header byte was part of the payload of a misaligned frame, so realign instead of dropping everything
this->resync_();
this->reader_.resync();
}
}
});
}
void DS1603L::dump_config() { LOG_SENSOR("", "DS1603L", this); }
bool DS1603L::parse_data_() {
uint8_t header = this->rx_buffer_[0];
uint8_t data_h = this->rx_buffer_[1];
uint8_t data_l = this->rx_buffer_[2];
uint8_t checksum = this->rx_buffer_[3];
bool DS1603L::parse_data_(const uint8_t *frame) {
uint8_t header = frame[0];
uint8_t data_h = frame[1];
uint8_t data_l = frame[2];
uint8_t checksum = frame[3];
uint8_t computed_checksum = (header + data_h + data_l) & 0xFF;
@@ -53,16 +44,4 @@ bool DS1603L::parse_data_() {
return true;
}
void DS1603L::resync_() {
// Drop the byte that was treated as the header, then look for the next candidate header in what is left
size_t start = 1;
while (start < this->rx_count_ && this->rx_buffer_[start] != HEADER_BYTE) {
start++;
}
this->rx_count_ -= start;
if (this->rx_count_ > 0) {
memmove(this->rx_buffer_, this->rx_buffer_ + start, this->rx_count_);
}
}
} // namespace esphome::ds1603l
+4 -6
View File
@@ -6,6 +6,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/frame_reader.h"
namespace esphome::ds1603l {
@@ -18,13 +19,10 @@ class DS1603L final : public sensor::Sensor, public Component, public uart::UART
static constexpr uint8_t HEADER_BYTE = 0xFF;
static constexpr size_t FRAME_SIZE = 4;
// Validates the checksum of the frame in rx_buffer_ and publishes it. Returns false if the frame is invalid.
bool parse_data_();
// Drops the first buffered byte and realigns the buffer on the next possible header byte.
void resync_();
// Validates the checksum of the frame and publishes it. Returns false if the frame is invalid.
bool parse_data_(const uint8_t *frame);
uint8_t rx_buffer_[FRAME_SIZE]; // Buffer for the frame being assembled
size_t rx_count_{0}; // Number of bytes currently in rx_buffer_
FrameReader<FRAME_SIZE, HEADER_BYTE> reader_;
};
} // namespace esphome::ds1603l
+10 -18
View File
@@ -1,33 +1,27 @@
#include "sm300d2.h"
#include "esphome/core/frame_reader.h"
#include "esphome/core/log.h"
namespace esphome::sm300d2 {
ESPHOME_LOG_TAG(TAG, "sm300d2");
static const uint8_t SM300D2_RESPONSE_LENGTH = 17;
static constexpr uint8_t SM300D2_RESPONSE_LENGTH = 17;
void SM300D2Sensor::update() {
uint8_t response[SM300D2_RESPONSE_LENGTH];
uint8_t peeked;
// Takes the first frame already received; only one cut off at the end of the buffer is waited for
FrameReader<SM300D2_RESPONSE_LENGTH, 0x3C, 0x02> reader;
const uint8_t *response = nullptr;
this->read_available<1>([&](uint8_t c) { return (response = reader.feed(c)) == nullptr; });
for (uint8_t c; response == nullptr && reader.length() > 0 && this->read_byte(&c);)
response = reader.feed(c);
while (this->available() > 0 && this->peek_byte(&peeked) && peeked != 0x3C)
this->read();
bool read_success = read_array(response, SM300D2_RESPONSE_LENGTH);
if (!read_success) {
if (response == nullptr) {
ESP_LOGW(TAG, "Reading data from SM300D2 failed!");
status_set_warning();
return;
}
if (response[0] != 0x3C || response[1] != 0x02) {
ESP_LOGW(TAG, "Invalid preamble for SM300D2 response!");
this->status_set_warning();
return;
}
uint16_t calculated_checksum = this->sm300d2_checksum_(response);
uint16_t calculated_checksum = sum_bytes(response, SM300D2_RESPONSE_LENGTH - 1);
// Occasionally the checksum has a +/- 0x80 offset. Negative temperatures are
// responsible for some of these. The rest are unknown/undocumented.
if ((calculated_checksum != response[SM300D2_RESPONSE_LENGTH - 1]) &&
@@ -83,8 +77,6 @@ void SM300D2Sensor::update() {
this->humidity_sensor_->publish_state(humidity);
}
uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); }
void SM300D2Sensor::dump_config() {
ESP_LOGCONFIG(TAG, "SM300D2:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
-2
View File
@@ -20,8 +20,6 @@ class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice {
void dump_config() override;
protected:
uint16_t sm300d2_checksum_(uint8_t *ptr);
sensor::Sensor *co2_sensor_{nullptr};
sensor::Sensor *formaldehyde_sensor_{nullptr};
sensor::Sensor *tvoc_sensor_{nullptr};
+22 -39
View File
@@ -44,8 +44,6 @@ static constexpr std::array<uint8_t, 7> GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11
static constexpr size_t FRAME_DEVICE_ID_INDEX = 2;
static constexpr size_t FRAME_CHECKSUM_INDEX = 30;
static constexpr size_t FRAME_STOP_INDEX = 31;
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64;
static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96;
static constexpr uint8_t FRAME_STOP_BYTE = 0x16;
@@ -120,9 +118,8 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp
static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) {
const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX);
return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") &&
check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") &&
check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") &&
// FrameReader already matched the start bytes
return check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") &&
check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") &&
check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") &&
check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") &&
@@ -215,7 +212,7 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) {
void UFM01Component::flush_rx_() {
this->discard_available();
this->read_index_ = 0;
this->active_reader_.reset();
}
void UFM01Component::send_command_no_wait_(const std::array<uint8_t, 7> &command) {
@@ -423,7 +420,7 @@ bool UFM01Component::service_software_version_(uint32_t now) {
}
#endif // USE_UFM01_SOFTWARE_VERSION
void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) {
void UFM01Component::on_active_frame_(const uint8_t data[FRAME_SIZE], uint32_t now) {
#ifdef USE_UFM01_METER_ID
this->publish_meter_id_from_frame_(data);
#endif
@@ -461,40 +458,26 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) {
bool UFM01Component::process_active_stream_(uint32_t now) {
bool got_valid_frame = false;
while (this->available()) {
if (!this->read_byte(&this->data_[this->read_index_])) {
ESP_LOGW(TAG, "unable to read byte");
this->read_index_ = 0;
continue;
}
if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) ||
(this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) {
ESP_LOGD(TAG, "not start of data at %" PRId32 " (is 0x%02X)", this->read_index_, this->data_[this->read_index_]);
this->read_index_ = 0;
continue;
}
if (++this->read_index_ < static_cast<int32_t>(FRAME_SIZE))
continue;
if (validate_active_frame(this->data_)) {
this->on_active_frame_(this->data_, now);
this->read_index_ = 0;
got_valid_frame = true;
continue;
}
log_hex(this->data_, sizeof(this->data_));
ESP_LOGW(TAG, "unable to read data");
for (int32_t i = 2;
i < static_cast<int32_t>(FRAME_STOP_INDEX) && this->read_index_ == static_cast<int32_t>(FRAME_SIZE); ++i) {
if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) {
for (int32_t j = i; j < static_cast<int32_t>(FRAME_SIZE); ++j)
this->data_[j - i] = this->data_[j];
this->read_index_ = static_cast<int32_t>(FRAME_SIZE) - i;
const bool read_ok = this->read_available([this, now, &got_valid_frame](uint8_t byte) {
const uint8_t *frame = this->active_reader_.feed(byte);
if (frame == nullptr) {
if (this->active_reader_.length() == 0) {
ESP_LOGD(TAG, "not start of data (is 0x%02X)", byte);
}
return;
}
if (this->read_index_ == static_cast<int32_t>(FRAME_SIZE))
this->read_index_ = 0;
if (validate_active_frame(frame)) {
this->on_active_frame_(frame, now);
got_valid_frame = true;
return;
}
log_hex(frame, FRAME_SIZE);
ESP_LOGW(TAG, "unable to read data");
this->active_reader_.resync();
});
if (!read_ok) {
ESP_LOGW(TAG, "unable to read byte");
this->active_reader_.reset();
}
return got_valid_frame;
+5 -3
View File
@@ -2,6 +2,7 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/frame_reader.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
@@ -25,6 +26,8 @@ class TestableUFM01;
} // namespace testing
static constexpr size_t FRAME_SIZE = 32;
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr size_t PASSIVE_FRAME_SIZE = 23;
static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39;
#ifdef USE_UFM01_METER_ID
@@ -108,7 +111,7 @@ class UFM01Component : public uart::UARTDevice, public Component {
bool consume_ack_();
void flush_rx_();
bool process_active_stream_(uint32_t now);
void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now);
void on_active_frame_(const uint8_t data[FRAME_SIZE], uint32_t now);
#ifdef USE_UFM01_METER_ID
void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]);
#endif
@@ -145,7 +148,6 @@ class UFM01Component : public uart::UARTDevice, public Component {
#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION
uint32_t pending_clear_start_ms_{0};
#endif
int32_t read_index_{0};
OperatingMode operating_mode_{OperatingMode::STARTUP};
StartupPhase startup_phase_{StartupPhase::WAIT};
@@ -170,7 +172,7 @@ class UFM01Component : public uart::UARTDevice, public Component {
#endif
uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE];
uint8_t data_[FRAME_SIZE];
FrameReader<FRAME_SIZE, FRAME_START_BYTE_1, FRAME_START_BYTE_2> active_reader_;
friend class testing::TestableUFM01;
};
+10 -42
View File
@@ -4,70 +4,38 @@
namespace esphome::wts01 {
constexpr uint8_t HEADER_1 = 0x55;
constexpr uint8_t HEADER_2 = 0x01;
constexpr uint8_t HEADER_3 = 0x01;
constexpr uint8_t HEADER_4 = 0x04;
ESPHOME_LOG_TAG(TAG, "wts01");
void WTS01Sensor::loop() {
this->read_available([this](uint8_t c) { this->handle_char_(c); });
this->read_available([this](uint8_t c) {
if (const uint8_t *packet = this->reader_.feed(c)) {
this->process_packet_(packet);
}
});
}
void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); }
void WTS01Sensor::handle_char_(uint8_t c) {
// State machine for processing the header. Reset if something doesn't match.
if (this->buffer_pos_ == 0 && c != HEADER_1) {
return;
}
if (this->buffer_pos_ == 1 && c != HEADER_2) {
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ == 2 && c != HEADER_3) {
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ == 3 && c != HEADER_4) {
this->buffer_pos_ = 0;
return;
}
// Add byte to buffer
this->buffer_[this->buffer_pos_++] = c;
// Process complete packet
if (this->buffer_pos_ >= PACKET_SIZE) {
this->process_packet_();
this->buffer_pos_ = 0;
}
}
void WTS01Sensor::process_packet_() {
void WTS01Sensor::process_packet_(const uint8_t *packet) {
// Based on Tasmota implementation
// Format: 55 01 01 04 01 11 16 12 95
// header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum
const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1);
const uint8_t calculated_checksum = sum_bytes(packet, PACKET_SIZE - 1);
uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1];
uint8_t received_checksum = packet[PACKET_SIZE - 1];
if (calculated_checksum != received_checksum) {
ESP_LOGW(TAG, "WTS01 Checksum doesn't match: 0x%02X != 0x%02X", received_checksum, calculated_checksum);
return;
}
// Extract temperature value
const uint8_t raw = this->buffer_[6];
const uint8_t raw = packet[6];
// WTS01 encodes sign in bit 7, magnitude in bits 0-6
const bool negative = (raw & 0x80) != 0;
const uint8_t magnitude = raw & 0x7F;
const float decimal = static_cast<float>(this->buffer_[7]) / 100.0f;
const float decimal = static_cast<float>(packet[7]) / 100.0f;
float temperature = static_cast<float>(magnitude) + decimal;
+3 -4
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/frame_reader.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
@@ -14,11 +15,9 @@ class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public
void dump_config() override;
protected:
uint8_t buffer_[PACKET_SIZE];
uint8_t buffer_pos_{0};
void process_packet_(const uint8_t *packet);
void handle_char_(uint8_t c);
void process_packet_();
FrameReader<PACKET_SIZE, 0x55, 0x01, 0x01, 0x04> reader_;
};
} // namespace esphome::wts01
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <type_traits>
namespace esphome {
/// Collects fixed length binary frames of N bytes that start with the header bytes HEADER...; the binary twin of
/// LineReader. Bytes before a header are skipped; checksums are left to the caller.
template<size_t N, uint8_t... HEADER> class FrameReader {
using pos_t = std::conditional_t<(N <= 0xFF), uint8_t, uint16_t>;
static constexpr size_t HEADER_LEN = sizeof...(HEADER);
static_assert(HEADER_LEN >= 1 && HEADER_LEN <= 8, "FrameReader takes 1 to 8 header bytes"); // bounds replay_() depth
static_assert(HEADER_LEN < N && N <= 0xFFFF, "FrameReader size out of range");
public:
/// Feed one byte; returns the completed frame, header included, valid until the next call, or nullptr.
const uint8_t *feed(uint8_t c) {
pos_t pos = this->pos_;
if (pos < HEADER_LEN && c != header_byte(pos)) {
// A later byte of the broken header, or c, may start the real one
this->buf_[pos] = c;
this->replay_(pos);
return nullptr;
}
this->buf_[pos++] = c;
if (pos < N) {
this->pos_ = pos;
return nullptr;
}
this->pos_ = 0;
return this->buf_;
}
/// Number of bytes of the partial frame; 0 after a frame is returned and while looking for a header.
size_t length() const { return this->pos_; }
/// Forget the partial frame and look for a header from the next byte.
void reset() { this->pos_ = 0; }
/// For a frame the caller rejected (bad checksum): drop its first byte and realign on a header in the rest.
/// Call only right after feed() returned that frame; use reset() otherwise.
void resync() { this->replay_(N - 1); }
protected:
// A fold, not an array, so no header table lands in RAM on ESP8266
static constexpr uint8_t header_byte(size_t i) {
size_t j = 0;
uint8_t r = 0;
((r = (j++ == i) ? HEADER : r), ...);
return r;
}
// Start over and feed buf_[1..last] again; never completes a frame as last < N
void replay_(size_t last) {
this->pos_ = 0;
for (size_t i = 1; i <= last; i++) {
this->feed(this->buf_[i]);
}
}
uint8_t buf_[N];
pos_t pos_{0};
};
} // namespace esphome
+143
View File
@@ -0,0 +1,143 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/core/frame_reader.h"
namespace esphome::core::testing {
using Bytes = std::vector<uint8_t>;
using Frames = std::vector<Bytes>;
// Feeds bytes and collects every completed frame.
template<size_t N, uint8_t... H> static Frames feed_all(FrameReader<N, H...> &reader, const Bytes &input) {
Frames frames;
for (uint8_t c : input) {
const uint8_t *frame = reader.feed(c);
if (frame != nullptr) {
EXPECT_EQ(reader.length(), 0u);
frames.emplace_back(frame, frame + N);
}
}
return frames;
}
static_assert(sizeof(FrameReader<4, 0xFF>) == 5);
static_assert(sizeof(FrameReader<9, 0x55, 0x01, 0x01, 0x04>) == 10);
static_assert(sizeof(FrameReader<0xFF, 0xAA>) == 0x100);
static_assert(sizeof(FrameReader<0x100, 0xAA>) == 0x102);
TEST(FrameReaderTest, SingleByteHeader) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0x01, 0x02, 0x02, 0xFF, 0x03, 0x04, 0x06}),
(Frames{{0xFF, 0x01, 0x02, 0x02}, {0xFF, 0x03, 0x04, 0x06}}));
}
TEST(FrameReaderTest, SkipsBytesBeforeHeader) {
FrameReader<3, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0x01, 0x02, 0xFF, 0x10, 0x20}), (Frames{{0xFF, 0x10, 0x20}}));
}
TEST(FrameReaderTest, HeaderByteInsidePayloadIsData) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0xFF, 0xFF, 0x00}), (Frames{{0xFF, 0xFF, 0xFF, 0x00}}));
}
TEST(FrameReaderTest, LengthTracksPartialFrame) {
FrameReader<4, 0xAA, 0xBB> reader;
EXPECT_EQ(reader.length(), 0u);
reader.feed(0x00);
EXPECT_EQ(reader.length(), 0u);
reader.feed(0xAA);
EXPECT_EQ(reader.length(), 1u);
reader.feed(0xBB);
reader.feed(0x01);
EXPECT_EQ(reader.length(), 3u);
reader.reset();
EXPECT_EQ(reader.length(), 0u);
EXPECT_EQ(feed_all(reader, {0x02, 0xAA, 0xBB, 0x03, 0x04}), (Frames{{0xAA, 0xBB, 0x03, 0x04}}));
}
TEST(FrameReaderTest, MultiByteHeaderMismatchRestarts) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x02, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, MismatchedByteCanStartHeader) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
// The second 0x55 breaks the first header and starts the real one
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, RepeatedHeaderPrefixIsKept) {
// AA AA AA BB: the mismatch at the third byte still leaves AA AA matched
FrameReader<4, 0xAA, 0xAA, 0xBB> reader;
EXPECT_EQ(feed_all(reader, {0xAA, 0xAA, 0xAA, 0xBB, 0x01}), (Frames{{0xAA, 0xAA, 0xBB, 0x01}}));
}
TEST(FrameReaderTest, OverlappingHeaderPrefix) {
// A1 B2 A1 B2: the mismatch on the last B2 keeps the trailing A1 B2 as the start of the header
FrameReader<5, 0xA1, 0xB2, 0xA1, 0xC3> reader;
EXPECT_EQ(feed_all(reader, {0xA1, 0xB2, 0xA1, 0xB2, 0xA1, 0xC3, 0x07}), (Frames{{0xA1, 0xB2, 0xA1, 0xC3, 0x07}}));
}
TEST(FrameReaderTest, ResyncFindsHeaderInRejectedFrame) {
FrameReader<4, 0xFF> reader;
// Started mid-frame: the 0xFF taken as a header was payload; the real frame starts at the second 0xFF
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x12, 0xFF, 0x01}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 2u);
EXPECT_EQ(feed_all(reader, {0x02, 0x02}), (Frames{{0xFF, 0x01, 0x02, 0x02}}));
}
TEST(FrameReaderTest, ResyncWithoutHeaderStartsOver) {
FrameReader<4, 0xFF> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x01, 0x02, 0x03}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 0u);
}
TEST(FrameReaderTest, ResyncKeepsPartialHeaderAtEnd) {
FrameReader<5, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x00, 0x00, 0x3C}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 1u);
EXPECT_EQ(feed_all(reader, {0x32, 0x01, 0x02, 0x03}), (Frames{{0x3C, 0x32, 0x01, 0x02, 0x03}}));
}
TEST(FrameReaderTest, ResyncMultiByteHeader) {
FrameReader<6, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x3C, 0x3C, 0x32, 0x09}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 3u);
EXPECT_EQ(feed_all(reader, {0x0A, 0x0B, 0x0C}), (Frames{{0x3C, 0x32, 0x09, 0x0A, 0x0B, 0x0C}}));
}
TEST(FrameReaderTest, SixteenBitIndex) {
FrameReader<300, 0xAA, 0x55> reader;
Bytes input{0x00, 0xAA, 0x55};
input.resize(input.size() + 298, 0x42);
Frames frames = feed_all(reader, input);
ASSERT_EQ(frames.size(), 1u);
EXPECT_EQ(frames[0][0], 0xAA);
EXPECT_EQ(frames[0][1], 0x55);
EXPECT_EQ(frames[0][299], 0x42);
}
} // namespace esphome::core::testing
@@ -1,82 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/display/init_sequence.h"
namespace esphome::display::testing {
TEST(InitSequenceReader, WalksCommandsAndDelays) {
static const uint8_t SEQ[] = {0x11, 0x00, 120, 0xFF, 0x3A, 0x01, 0x55, 0x2A, 0x02, 0x00, 0xEF};
InitSequenceReader reader(SEQ, sizeof(SEQ));
InitSequenceEntry entry{};
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(entry.value, 0x11);
EXPECT_EQ(entry.count, 0);
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_DELAY);
EXPECT_EQ(entry.value, 120);
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(entry.value, 0x3A);
ASSERT_EQ(entry.count, 1);
EXPECT_EQ(entry.args, &SEQ[6]);
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(entry.value, 0x2A);
ASSERT_EQ(entry.count, 2);
EXPECT_EQ(entry.args[0], 0x00);
EXPECT_EQ(entry.args[1], 0xEF);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
}
TEST(InitSequenceReader, EmptySequenceEnds) {
InitSequenceReader reader(nullptr, 0);
InitSequenceEntry entry{};
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
}
// A count above 127 is a real length, not a flag bit.
TEST(InitSequenceReader, HonoursFullCount) {
uint8_t seq[2 + 253 + 2] = {0x32, 253};
seq[2 + 253] = 0x29;
InitSequenceReader reader(seq, sizeof(seq));
InitSequenceEntry entry{};
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(entry.value, 0x32);
EXPECT_EQ(entry.count, 253);
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(entry.value, 0x29);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
}
TEST(InitSequenceReader, TruncatedHeaderIsMalformed) {
static const uint8_t SEQ[] = {0x11, 0x00, 0x29};
InitSequenceReader reader(SEQ, sizeof(SEQ));
InitSequenceEntry entry{};
ASSERT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_COMMAND);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_MALFORMED);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
}
TEST(InitSequenceReader, ShortArgsAreMalformed) {
static const uint8_t SEQ[] = {0x2A, 0x04, 0x00, 0x00, 0x00};
InitSequenceReader reader(SEQ, sizeof(SEQ));
InitSequenceEntry entry{};
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_MALFORMED);
EXPECT_EQ(reader.next(entry), INIT_SEQUENCE_STEP_END);
}
TEST(InitSequenceReader, CountOverMaxIsMalformed) {
static const uint8_t SEQ[] = {10, 0xFF, 0x2A, 0x02, 0x00, 0xEF};
InitSequenceReader reader(SEQ, sizeof(SEQ));
InitSequenceEntry entry{};
ASSERT_EQ(reader.next(entry, 1), INIT_SEQUENCE_STEP_DELAY);
EXPECT_EQ(reader.next(entry, 1), INIT_SEQUENCE_STEP_MALFORMED);
}
} // namespace esphome::display::testing
+3 -5
View File
@@ -20,8 +20,6 @@
namespace esphome::ufm01::testing {
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64;
static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96;
static constexpr uint8_t FRAME_STOP_BYTE = 0x16;
@@ -131,7 +129,7 @@ class TestableUFM01 : public UFM01Component {
StartupPhase startup_phase() const { return this->startup_phase_; }
int32_t read_index() const { return this->read_index_; }
int32_t read_index() const { return this->active_reader_.length(); }
uint8_t passive_index() const { return this->passive_index_; }
@@ -165,7 +163,7 @@ class TestableUFM01 : public UFM01Component {
this->operating_mode_ = OperatingMode::ACTIVE_STREAM;
this->last_valid_frame_ms_ = now();
this->passive_read_pending_ = false;
this->read_index_ = 0;
this->active_reader_.reset();
}
void begin_active_wait_frame() {
@@ -182,7 +180,7 @@ class TestableUFM01 : public UFM01Component {
}
void reset_state() {
this->read_index_ = 0;
this->active_reader_.reset();
this->last_valid_frame_ms_ = 0;
this->passive_index_ = 0;
this->passive_read_pending_ = false;
@@ -30,6 +30,15 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) {
EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u);
}
TEST_F(UFM01Test, RejectedActiveFrameResyncsOnStartBytesInside) {
// A cut off frame start makes the first 32 bytes invalid; the real frame starts inside them
this->mock_uart_.enqueue({FRAME_START_BYTE_1, FRAME_START_BYTE_2, 0x00, 0x00});
this->mock_uart_.enqueue(make_active_frame());
EXPECT_TRUE(this->ufm01_.process_active_stream());
EXPECT_EQ(this->ufm01_.read_index(), 0);
}
TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) {
auto frame = make_passive_frame();
this->mock_uart_.enqueue(frame);
+2 -20
View File
@@ -1,4 +1,4 @@
"""Tests for display.add_writer and display.config_has_writer."""
"""Tests for display.add_writer."""
from unittest.mock import AsyncMock, MagicMock, patch
@@ -6,8 +6,7 @@ import pytest
from esphome import codegen as cg
from esphome.components import display
from esphome.components.display import CONF_SHOW_TEST_CARD
from esphome.const import CONF_AUTO_CLEAR_ENABLED, CONF_LAMBDA, CONF_PAGES
from esphome.const import CONF_LAMBDA
@pytest.mark.parametrize(
@@ -40,20 +39,3 @@ async def test_add_writer_without_lambda_does_nothing() -> None:
process.assert_not_awaited()
add.assert_not_called()
@pytest.mark.parametrize(
("config", "expected"),
[
({}, False),
({CONF_AUTO_CLEAR_ENABLED: True}, True),
({CONF_AUTO_CLEAR_ENABLED: False}, False),
({CONF_AUTO_CLEAR_ENABLED: "unspecified"}, False),
({CONF_PAGES: []}, True),
({CONF_LAMBDA: "x"}, True),
({CONF_SHOW_TEST_CARD: True}, True),
({CONF_SHOW_TEST_CARD: False}, False),
],
)
def test_config_has_writer(config: dict, expected: bool) -> None:
assert display.config_has_writer(config) is expected