Compare commits

..
Author SHA1 Message Date
4242bcb084 [vnc] Add the Hextile encoding and skip unchanged tiles (#20625)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 21:52:53 +00:00
J. Nick Koston 8a88e97497 [weikai] Share print_buffer between the SPI and I2C drivers (#20721) 2026-10-22 21:21:41 +00:00
Frédéric MetrichandClaude d586e20a31 [teleinfo] Fix default units of unitless, text and PMAX tags (#21367)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 17:21:09 -04:00
e6161e64b1 [rx8111] Add alarm and wake-up timer support (#21217)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 21:14:37 +00:00
J. Nick Koston 431843c27e [tests] Use LineWaiter for the integration tests that parse or count log lines (#21306) 2026-10-22 11:14:34 -10:00
J. Nick Koston a1da3d6b90 [unicode] Stop reading past a truncated UTF-8 sequence (#21374) 2026-10-22 11:14:17 -10:00
J. Nick Koston 930429061a [tests] Use LineWaiter for the integration tests that only wait on log lines (#21305) 2026-10-22 11:14:01 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] afceb91d90 [tests] Use wait_or_fail for the remaining integration test timeouts (#21304)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 11:13:55 -10:00
J. Nick Koston 7d741361a3 [weikai] Keep the register name tables in flash (#20712) 2026-10-22 11:13:04 -10:00
miloit 9c70ffd826 [ds3231] Add time platform, alarms, binary sensors and output control (#21054) 2026-10-22 10:58:28 -10:00
159 changed files with 6256 additions and 3422 deletions
+239 -6
View File
@@ -1,7 +1,18 @@
from esphome import automation
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_HOUR,
CONF_INTERRUPT_PIN,
CONF_MINUTE,
CONF_MODE,
CONF_OFFSET,
CONF_SECOND,
CONF_STATE,
)
from esphome.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
@@ -10,23 +21,245 @@ MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
CONF_ALARM = "alarm"
CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval"
CONF_AGING_OFFSET = "aging_offset"
CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms"
CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave"
CONF_DAY_OF_MONTH = "day_of_month"
CONF_DAY_OF_WEEK = "day_of_week"
CONF_ON_ALARM_1 = "on_alarm_1"
CONF_ON_ALARM_2 = "on_alarm_2"
CONF_OUTPUT_32KHZ = "output_32khz"
CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
AlarmMode = ds3231_ns.enum("AlarmMode")
ALARM_1_MODES = AlarmMode.options(
"every_second",
"every_minute",
"hourly",
"daily",
"monthly",
"weekly",
prefix="ALARM_MODE_",
)
# Alarm 2 has no seconds register.
ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"}
SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency")
# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it.
SQUARE_WAVE_FREQUENCIES = {
1: SquareWaveFrequency.SQUARE_WAVE_1HZ,
1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ,
4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ,
8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ,
}
validate_square_wave_frequency = cv.All(
cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True)
)
validate_aging_offset = cv.int_range(min=-128, max=127)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS3231Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency,
cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean,
cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean,
cv.Optional(CONF_AGING_OFFSET): validate_aging_offset,
cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean,
cv.Optional(
CONF_ALARM_CHECK_INTERVAL, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True),
cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68))
.extend(i2c.i2c_device_schema(0x68)),
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
var = await i2c.new_i2c_component(config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
cg.add_optional(
var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY)
)
cg.add_optional(
var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE)
)
cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ))
cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET))
cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS]))
cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL]))
# The alarm flags are only read when something uses them; the binary sensor platform
# turns this on as well. With an interrupt pin the check also releases INT/SQW.
if any(
key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2)
):
cg.add(var.set_monitor_alarms(True))
# One callback for both alarms; each automation runs only for its own alarm number.
for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)):
if (conf := config.get(conf_key)) is not None:
await automation.build_callback_automation(
var,
"add_on_alarm_callback",
[],
conf,
params=[(cg.uint8, "alarm")],
forward=[],
when=f"alarm == {alarm}",
)
# ---------------------------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------------------------
DS3231_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Component)}
)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
DS3231_ACTION_SCHEMA,
automation.ApplyCall("force_temperature_conversion()"),
)
def _validate_alarm_day(config: ConfigType) -> ConfigType:
mode = config[CONF_MODE]
if mode == "monthly" and CONF_DAY_OF_MONTH not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode")
if mode == "weekly" and CONF_DAY_OF_WEEK not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode")
# The unused day still has to be a valid register value.
config.setdefault(CONF_DAY_OF_MONTH, 1)
config.setdefault(CONF_DAY_OF_WEEK, 1)
return config
def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema:
schema = {
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_MODE): cv.enum(modes, lower=True),
cv.Optional(CONF_MINUTE, default=0): cv.templatable(
cv.int_range(min=0, max=59)
),
cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)),
# 1 = Sunday ... 7 = Saturday, as in ESPHome's time component.
cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean),
}
if with_second:
schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable(
cv.int_range(min=0, max=59)
)
return cv.All(cv.Schema(schema), _validate_alarm_day)
automation.register_apply_action(
"ds3231.set_alarm_1",
_alarm_schema(ALARM_1_MODES, with_second=True),
automation.ApplyCall(
"set_alarm_1({}, {}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_SECOND, cg.uint8),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
automation.register_apply_action(
"ds3231.set_alarm_2",
_alarm_schema(ALARM_2_MODES, with_second=False),
automation.ApplyCall(
"set_alarm_2({}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
DS3231_ALARM_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)),
}
)
for _name, _method in (
("ds3231.enable_alarm", "enable_alarm"),
("ds3231.disable_alarm", "disable_alarm"),
("ds3231.clear_alarm", "clear_alarm"),
):
automation.register_apply_action(
_name,
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyField(CONF_ALARM, _method, cg.uint8),
)
automation.register_apply_condition(
"ds3231.is_alarm_triggered",
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)),
)
automation.register_apply_action(
"ds3231.enable_square_wave",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_FREQUENCY): validate_square_wave_frequency,
}
),
automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency),
)
automation.register_apply_action(
"ds3231.disable_square_wave",
DS3231_ACTION_SCHEMA,
automation.ApplyCall("disable_square_wave()"),
)
automation.register_apply_action(
"ds3231.set_32khz_output",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
}
),
automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_),
)
automation.register_apply_action(
"ds3231.set_aging_offset",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset),
}
),
automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8),
)
@@ -0,0 +1,47 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component
DEPENDENCIES = ["ds3231"]
CONF_ALARM_1 = "alarm_1"
CONF_ALARM_2 = "alarm_2"
CONF_OSCILLATOR_STOPPED = "oscillator_stopped"
ICON_ALARM = "mdi:alarm"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_PROBLEM,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
),
cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DS3231_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor)
await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor)
await binary_sensors(
CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor
)
if CONF_ALARM_1 in config or CONF_ALARM_2 in config:
cg.add(hub.set_monitor_alarms(True))
+423 -14
View File
@@ -9,29 +9,184 @@ namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231");
static constexpr uint8_t DS3231_REG_SECONDS = 0x00;
static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07;
static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B;
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
static constexpr uint8_t DS3231_REG_STATUS = 0x0F;
static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10;
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching
static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date
/// How long an alarm binary sensor stays on when the flag is cleared automatically.
static constexpr uint32_t ALARM_PULSE_MS = 1000;
static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) {
return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled");
}
static const LogString *square_wave_to_string(SquareWaveFrequency frequency) {
switch (frequency) {
case SQUARE_WAVE_1HZ:
return LOG_STR("1 Hz");
case SQUARE_WAVE_1024HZ:
return LOG_STR("1.024 kHz");
case SQUARE_WAVE_4096HZ:
return LOG_STR("4.096 kHz");
case SQUARE_WAVE_8192HZ:
return LOG_STR("8.192 kHz");
default:
return LOG_STR("unknown");
}
}
void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); }
void DS3231Component::setup() {
if (!this->read_control_status_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
this->control_reg_ |= CONTROL_INTCN;
if (!this->write_control_()) {
// Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square
// wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms
// live on the chip and survive a reboot of the ESP on battery.
uint8_t mask = CONTROL_EOSC | CONTROL_INTCN;
uint8_t value = CONTROL_INTCN;
if (this->square_wave_configured_) {
mask |= CONTROL_RS_MASK;
value = this->square_wave_frequency_ << CONTROL_RS_SHIFT;
}
if (this->battery_backed_square_wave_ >= 0) {
mask |= CONTROL_BBSQW;
assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0);
}
if (!this->update_control_(mask, value)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (this->output_32khz_ >= 0) {
assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0);
if (!this->write_status_(0)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid.");
}
this->publish_oscillator_stopped_();
if (!this->monitor_alarms_) {
this->disable_loop();
return;
}
// Start from a known state; an alarm that fired while the ESP was off is picked up by the first
// check, which runs from the loop right after setup.
this->publish_alarm_state_(1, false);
this->publish_alarm_state_(2, false);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// INT/SQW is open drain and active low: a falling edge means an enabled alarm matched.
// The handler only wakes the loop; the flags are read from there.
this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
}
// Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an
// edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere.
this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); });
}
void DS3231Component::loop() {
// Only runs once after setup and when woken by the interrupt pin.
this->disable_loop();
this->check_alarms_();
}
void DS3231Component::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231:");
LOG_I2C_DEVICE(this);
if (this->is_failed())
return;
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
if ((this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s",
LOG_STR_ARG(square_wave_to_string(
static_cast<SquareWaveFrequency>((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT))));
} else {
ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt");
}
ESP_LOGCONFIG(TAG,
" Battery-backed square wave: %s\n"
" 32 kHz output: %s\n"
" Alarm 1: %s\n"
" Alarm 2: %s\n"
" Auto clear alarms: %s",
YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_));
if (this->aging_offset_configured_) {
ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_);
}
if (this->monitor_alarms_) {
ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_);
}
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_);
#endif
}
// ---------------------------------------------------------------------------------------------
// Clock
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_calendar(uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted.
if (!this->read_control_status_())
return false;
this->publish_oscillator_stopped_();
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first.");
return false;
}
return true;
}
bool DS3231Component::write_calendar(const uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep
// reporting its time as invalid, so the write is not reported as successful.
if (!this->read_control_status_())
return false;
if ((this->status_reg_ & STATUS_OSF) != 0) {
if (!this->write_status_(STATUS_OSF))
return false;
this->status_reg_ &= ~STATUS_OSF;
}
this->publish_oscillator_stopped_();
return true;
}
// ---------------------------------------------------------------------------------------------
// Temperature
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_temperature(float &out) {
uint8_t raw[2];
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
@@ -53,26 +208,280 @@ void DS3231Component::force_temperature_conversion() {
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
return;
}
this->control_reg_ |= CONTROL_CONV;
this->write_control_();
// CONV is a one-shot command bit, so it is not kept in the cached value.
this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV));
}
// ---------------------------------------------------------------------------------------------
// Alarms
// ---------------------------------------------------------------------------------------------
void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 ||
day_of_week > 7 || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 1 setting.");
return;
}
// Mask bits A1M1..A1M4: a set bit means the register is ignored when matching.
uint8_t regs[4] = {
uint8_to_bcd(second),
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_SECOND:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_EVERY_MINUTE:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[2] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[3] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(1, regs, sizeof(regs), enabled);
}
void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 ||
mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 2 setting.");
return;
}
// Mask bits A2M2..A2M4.
uint8_t regs[3] = {
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_MINUTE:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[2] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(2, regs, sizeof(regs), enabled);
}
void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) {
if (this->is_failed())
return;
if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len)))
return;
// Changing the registers may have matched on the way: start from a clean flag. Enabling the
// alarm with a stale flag would assert INT/SQW right away.
if (!this->clear_alarm_flag_(alarm)) {
ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm);
return;
}
this->set_alarm_enabled_(alarm, enabled);
}
void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return;
}
if (this->is_failed())
return;
const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
if (!this->update_control_(bit, enabled ? bit : 0))
return;
if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm);
}
ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
}
void DS3231Component::enable_alarm(uint8_t alarm) {
// The chip sets the flag on every match even while the alarm is disabled; enabling with it set
// would fire the trigger (and INT/SQW) for that old match right away.
if (this->clear_alarm_flag_(alarm))
this->set_alarm_enabled_(alarm, true);
}
void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); }
bool DS3231Component::clear_alarm_flag_(uint8_t alarm) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return false;
}
if (this->is_failed() || !this->read_control_status_())
return false;
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
if ((this->status_reg_ & flag) != 0) {
// On failure the flag is still set on the chip: keep reporting the alarm as it is.
if (!this->write_status_(flag))
return false;
this->status_reg_ &= ~flag;
}
this->alarm_active_[alarm - 1] = false;
this->publish_alarm_state_(alarm, false);
return true;
}
bool DS3231Component::is_alarm_triggered(uint8_t alarm) {
if (this->is_failed() || !this->read_control_status_())
return false;
return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0;
}
void DS3231Component::check_alarms_() {
if (this->is_failed() || !this->read_control_status_())
return;
this->publish_oscillator_stopped_();
uint8_t to_clear = 0;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
const bool triggered = (this->status_reg_ & flag) != 0;
const bool enabled = (this->control_reg_ & enable) != 0;
bool &active = this->alarm_active_[alarm - 1];
if (!triggered) {
if (active && !this->auto_clear_alarms_)
this->publish_alarm_state_(alarm, false);
active = false;
continue;
}
// The flag is set on every match even when the alarm is disabled; only enabled alarms count.
if (enabled && !active) {
ESP_LOGI(TAG, "Alarm %u triggered.", alarm);
active = true;
this->publish_alarm_state_(alarm, true);
this->alarm_callback_.call(alarm);
}
if (this->auto_clear_alarms_)
to_clear |= flag;
}
if (to_clear != 0 && this->write_status_(to_clear)) {
this->status_reg_ &= ~to_clear;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1])
continue;
this->alarm_active_[alarm - 1] = false;
#ifdef USE_BINARY_SENSOR
// Keep the binary sensor on long enough for the state to be seen, then turn it off again.
this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS,
[this, alarm]() { this->publish_alarm_state_(alarm, false); });
#endif
}
}
}
void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) {
#ifdef USE_BINARY_SENSOR
auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_;
if (sensor != nullptr)
sensor->publish_state(state);
#endif
}
void DS3231Component::publish_oscillator_stopped_() {
#ifdef USE_BINARY_SENSOR
if (this->oscillator_stopped_binary_sensor_ != nullptr)
this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0);
#endif
}
// ---------------------------------------------------------------------------------------------
// Outputs and calibration
// ---------------------------------------------------------------------------------------------
void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) {
if (this->interrupt_pin_ != nullptr) {
ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin.");
return;
}
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) {
ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency)));
}
}
void DS3231Component::disable_square_wave() {
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) {
ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt.");
}
}
void DS3231Component::set_output_32khz(bool enabled) {
if (this->is_failed() || !this->read_control_status_())
return;
assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled);
if (this->write_status_(0)) {
ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled));
}
}
void DS3231Component::set_aging_offset(int8_t offset) {
if (this->is_failed())
return;
if (!this->write_aging_offset_(offset))
return;
ESP_LOGD(TAG, "Aging offset set to %d.", offset);
// The new offset takes effect with the next temperature conversion.
this->force_temperature_conversion();
}
// ---------------------------------------------------------------------------------------------
// Register access
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_control_status_() {
uint8_t raw[2];
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
ESP_LOGW(TAG, "Can't read control/status registers.");
if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
return false;
}
this->control_reg_ = raw[0];
this->control_reg_ = raw[0] & ~CONTROL_CONV;
this->status_reg_ = raw[1];
return true;
}
bool DS3231Component::write_control_() {
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
ESP_LOGW(TAG, "Can't write control register.");
bool DS3231Component::update_control_(uint8_t mask, uint8_t value) {
// CONV is a one-shot command bit: always write it back as 0 so a control change never starts
// a temperature conversion.
if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value)))
return false;
}
this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask);
return true;
}
bool DS3231Component::write_status_(uint8_t clear) {
// The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set
// between the read and this write is never lost.
const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear;
return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value));
}
bool DS3231Component::write_aging_offset_(int8_t offset) {
if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast<uint8_t>(offset))))
return false;
this->aging_offset_ = offset;
return true;
}
+132 -1
View File
@@ -1,32 +1,163 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::ds3231 {
/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND.
enum AlarmMode : uint8_t {
ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only.
ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute.
ALARM_MODE_HOURLY, ///< When minutes (and seconds) match.
ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match.
ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match.
ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match.
};
/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register).
enum SquareWaveFrequency : uint8_t {
SQUARE_WAVE_1HZ = 0,
SQUARE_WAVE_1024HZ = 1,
SQUARE_WAVE_4096HZ = 2,
SQUARE_WAVE_8192HZ = 3,
};
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
/// chip through this class.
class DS3231Component final : public Component, public i2c::I2CDevice {
public:
/// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR.
static constexpr size_t CALENDAR_LEN = 7;
void setup() override;
void loop() override;
void dump_config() override;
// ---- Configuration (set from codegen) ----
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_square_wave_frequency(SquareWaveFrequency frequency) {
this->square_wave_frequency_ = frequency;
this->square_wave_configured_ = true;
}
void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; }
void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; }
void set_aging_offset_config(int8_t offset) {
this->aging_offset_ = offset;
this->aging_offset_configured_ = true;
}
void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; }
void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; }
void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; }
/// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches.
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callback_.add(std::forward<F>(callback));
}
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(alarm_1)
SUB_BINARY_SENSOR(alarm_2)
SUB_BINARY_SENSOR(oscillator_stopped)
#endif
// ---- Clock registers (used by the time platform) ----
/// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set.
bool read_calendar(uint8_t *raw);
/// Write the calendar registers from @p raw and clear the oscillator-stop flag.
bool write_calendar(const uint8_t *raw);
// ---- Temperature ----
bool read_temperature(float &out);
void force_temperature_conversion();
// ---- Alarms ----
/// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode.
void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled);
/// Program alarm 2 (minute resolution, fires at second 00).
void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week,
bool enabled);
/// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires.
void enable_alarm(uint8_t alarm);
void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); }
/// Clear the alarm flag, which also releases the INT/SQW line.
void clear_alarm(uint8_t alarm);
/// True while the alarm flag of @p alarm (1 or 2) is set on the chip.
bool is_alarm_triggered(uint8_t alarm);
// ---- Outputs and calibration ----
/// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin.
void enable_square_wave(SquareWaveFrequency frequency);
/// Switch INT/SQW back to the (active-low) alarm interrupt output.
void disable_square_wave();
void set_output_32khz(bool enabled);
/// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down.
void set_aging_offset(int8_t offset);
protected:
static constexpr uint8_t CONTROL_A1IE = 0x01;
static constexpr uint8_t CONTROL_A2IE = 0x02;
static constexpr uint8_t CONTROL_INTCN = 0x04;
static constexpr uint8_t CONTROL_RS_MASK = 0x18;
static constexpr uint8_t CONTROL_RS_SHIFT = 3;
static constexpr uint8_t CONTROL_CONV = 0x20;
static constexpr uint8_t CONTROL_BBSQW = 0x40;
static constexpr uint8_t CONTROL_EOSC = 0x80;
static constexpr uint8_t STATUS_A1F = 0x01;
static constexpr uint8_t STATUS_A2F = 0x02;
static constexpr uint8_t STATUS_BSY = 0x04;
static constexpr uint8_t STATUS_EN32KHZ = 0x08;
static constexpr uint8_t STATUS_OSF = 0x80;
static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF;
/// Scheduler ids: one pulse timer per alarm (id + alarm - 1).
static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0;
static void interrupt_handler(DS3231Component *arg);
/// Read the control and status registers into the cached copies. Sets the warning flag on failure.
bool read_control_status_();
bool write_control_();
/// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache.
bool update_control_(uint8_t mask, uint8_t value);
/// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged).
bool write_status_(uint8_t clear);
bool write_aging_offset_(int8_t offset);
/// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails.
bool clear_alarm_flag_(uint8_t alarm);
void set_alarm_enabled_(uint8_t alarm, bool enabled);
/// Write the alarm registers, clear the stale flag and set the interrupt enable.
void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled);
/// Read the flags, fire triggers and update the binary sensors.
void check_alarms_();
void publish_alarm_state_(uint8_t alarm, bool state);
void publish_oscillator_stopped_();
InternalGPIOPin *interrupt_pin_{nullptr};
LazyCallbackManager<void(uint8_t)> alarm_callback_;
uint32_t alarm_check_interval_{1000};
uint8_t control_reg_{0};
uint8_t status_reg_{0};
int8_t aging_offset_{0};
SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ};
/// -1: leave the chip as it is, 0: off, 1: on.
int8_t battery_backed_square_wave_{-1};
int8_t output_32khz_{-1};
bool square_wave_configured_{false};
bool aging_offset_configured_{false};
bool auto_clear_alarms_{true};
bool monitor_alarms_{false};
/// Set while an alarm flag has been reported, so a latched flag fires its trigger only once.
bool alarm_active_[2]{false, false};
};
} // namespace esphome::ds3231
@@ -0,0 +1,40 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import time
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
DEPENDENCIES = ["ds3231"]
DS3231Time = ds3231_ns.class_(
"DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component)
)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(DS3231Time),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
}
)
DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Time)}
)
automation.register_apply_action(
"ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()")
)
automation.register_apply_action(
"ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()")
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_DS3231_ID])
await time.register_time(var, config)
@@ -0,0 +1,48 @@
#include "ds3231_time.h"
#include "esphome/core/log.h"
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231.time");
void DS3231Time::update() { this->read_time(); }
void DS3231Time::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231 Time:");
RealTimeClock::dump_config();
}
void DS3231Time::read_time() {
uint8_t raw[DS3231Component::CALENDAR_LEN];
if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC")))
return;
const ESPTime rtc_time = time::decode_bcd_calendar<time::DS_BCD_CALENDAR>(raw);
ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month,
rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week);
// The day of week is a free-running counter on the DS3231 and may have been set with a different
// numbering by other software, so it is not used to validate the time.
if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false),
LOG_STR("Invalid RTC time")))
return;
this->synchronize_epoch_(rtc_time.timestamp);
}
void DS3231Time::write_time() {
auto now = this->utcnow();
if (!now.is_valid()) {
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
return;
}
if (now.year > 2199) {
ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year);
return;
}
uint8_t raw[DS3231Component::CALENDAR_LEN];
time::encode_bcd_calendar<time::DS_BCD_CALENDAR>(now, raw);
if (!this->status_comm_result(this->parent_->write_calendar(raw)))
return;
ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
} // namespace esphome::ds3231
@@ -0,0 +1,22 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/time/real_time_clock.h"
#include "../ds3231.h"
namespace esphome::ds3231 {
class DS3231Time final : public time::RealTimeClock, public Parented<DS3231Component> {
public:
void update() override;
void dump_config() override;
/// Set the system clock from the RTC.
void read_time();
/// Store the system clock in the RTC.
void write_time();
};
} // namespace esphome::ds3231
+6 -15
View File
@@ -94,12 +94,10 @@ DEFAULT_WS_PATH = "/mqtt"
# Max lengths for stack-based topic building.
# These values are used in cv.Length() validators below to ensure the C++ code
# can safely use fixed-size stack buffers without overflow. If you change these,
# update the corresponding constants in mqtt_client.h and mqtt_component.h.
# in mqtt_component.cpp can safely use fixed-size stack buffers without overflow.
# If you change these, update the corresponding constants in mqtt_component.cpp.
TOPIC_PREFIX_MAX_LEN = 64 # Default is device name, typically short
DISCOVERY_PREFIX_MAX_LEN = 64 # Default is "homeassistant" (13 chars)
# Size of the stack buffer topics are built into (static_assert in mqtt_client.cpp)
MQTT_DEFAULT_TOPIC_MAX_LEN = 248
def validate_message_just_topic(value):
@@ -129,15 +127,6 @@ MQTT_MESSAGE_SCHEMA = cv.Any(
)
mqtt_ns = cg.esphome_ns.namespace("mqtt")
def _topic_source(topic: str) -> cg.MockObj:
# On ESP8266 a topic that fits the buffer stays in flash and is copied out when needed
if CORE.is_esp8266 and len(topic.encode()) < MQTT_DEFAULT_TOPIC_MAX_LEN:
return mqtt_ns.progmem_topic(cg.FlashStringLiteral(topic))
return mqtt_ns.static_topic(topic)
MQTTMessage = mqtt_ns.struct("MQTTMessage")
MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason")
MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component)
@@ -577,7 +566,9 @@ async def to_code(config):
((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),),
),
)
cg.add(var.subscribe(_topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS]))
cg.add(
var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS])
)
for conf in config.get(CONF_ON_JSON_MESSAGE, []):
callback = await automation.build_trigger_callback(
@@ -588,7 +579,7 @@ async def to_code(config):
)
cg.add(
var.subscribe_json(
_topic_source(conf[CONF_TOPIC]), callback, conf[CONF_QOS]
cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]
)
)
@@ -50,7 +50,7 @@ static bool apply_command(AlarmControlPanelCall &call, const char *state) {
void MQTTAlarmControlPanelComponent::setup() {
this->alarm_control_panel_->add_on_state_callback(
[this](AlarmControlPanelState /*state*/) { this->publish_state(); });
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->alarm_control_panel_->make_call();
if (!payload.empty() && payload[0] == '{') {
// JSON payload: {"state": "DISARM", "code": "1234"}
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::button;
MQTTButtonComponent::MQTTButtonComponent(button::Button *button) : button_(button) {}
void MQTTButtonComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
if (payload == "PRESS") {
this->button_->press();
} else {
+12 -62
View File
@@ -98,16 +98,12 @@ void MQTTClientComponent::setup() {
if (this->is_discovery_ip_enabled()) {
this->subscribe(
static_topic("esphome/discover"),
[this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2);
"esphome/discover", [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); },
2);
static_assert(sizeof("esphome/ping/") + ESPHOME_DEVICE_NAME_MAX_LEN <= MQTT_DEFAULT_TOPIC_MAX_LEN);
MQTTTopicSource ping_topic{[](const void *, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
size_t len = buf_append_printf(buf.data(), buf.size(), 0, "esphome/ping/%s",
App.get_name().c_str());
return StringRef(buf.data(), len);
},
nullptr};
// Format topic on stack - subscribe() copies it
char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(ping_topic, "ping");
this->subscribe(
ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2);
}
@@ -483,12 +479,6 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) {
}
return ret != 0;
}
// Out of line so the topic buffer is not part of loop()'s stack frame
__attribute__((noinline)) bool MQTTClientComponent::subscribe_topic_(const MQTTTopicSource &topic, uint8_t qos) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return this->subscribe_(topic.build(buf).c_str(), qos);
}
void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
if (sub->subscribed)
return;
@@ -497,7 +487,7 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
bool do_resub = sub->resubscribe_timeout == 0 || now - sub->resubscribe_timeout > 1000;
if (do_resub) {
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
sub->subscribed = this->subscribe_(sub->topic.c_str(), sub->qos);
sub->resubscribe_timeout = now;
}
}
@@ -520,47 +510,16 @@ void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, co
}
#endif
static StringRef static_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) {
return StringRef(static_cast<const char *>(context));
}
// Same as static_topic_builder, but a distinct function so its address marks the topics to free
static StringRef copied_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) {
return StringRef(static_cast<const char *>(context));
}
MQTTTopicSource static_topic(const char *topic) { return {static_topic_builder, topic}; }
static_assert(MQTT_DEFAULT_TOPIC_MAX_LEN == 248, "update MQTT_DEFAULT_TOPIC_MAX_LEN in mqtt/__init__.py");
#ifdef USE_ESP8266
// mqtt/__init__.py picks this source only for topics that fit
static StringRef progmem_topic_builder(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
size_t len = buf_append_str_p(buf.data(), buf.size(), 0, static_cast<const char *>(context));
return StringRef(buf.data(), len);
}
MQTTTopicSource progmem_topic(ProgmemStr topic) {
return {progmem_topic_builder, reinterpret_cast<const char *>(topic)};
}
#endif
MQTTTopicSource MQTTClientComponent::copy_topic(StringRef topic) {
char *copy = new char[topic.size() + 1]; // NOLINT(cppcoreguidelines-owning-memory)
memcpy(copy, topic.c_str(), topic.size());
copy[topic.size()] = '\0';
return {copied_topic_builder, copy};
}
void MQTTClientComponent::add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed,
void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed,
uint8_t qos) {
auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{
.topic = topic,
.callback = callback,
.resubscribe_timeout = 0,
.topic = std::move(topic),
.qos = qos,
.subscribed = false,
.boxed = boxed,
.removed = false,
.callback = callback,
.resubscribe_timeout = 0,
});
this->resubscribe_subscription_(&subscription);
}
@@ -576,9 +535,8 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) {
this->status_momentary_warning(1000);
}
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
for (auto &subscription : this->subscriptions_) {
if (subscription.topic.build(buf) == topic)
if (subscription.topic == topic)
subscription.removed = true;
}
// A callback may be running; the outermost dispatch erases them once it returns.
@@ -595,8 +553,6 @@ void MQTTClientComponent::erase_removed_subscriptions_() {
if (it->removed) {
if (it->boxed)
it->callback.free_boxed();
if (it->topic.builder == copied_topic_builder)
delete[] static_cast<const char *>(it->topic.context); // NOLINT(cppcoreguidelines-owning-memory)
it = this->subscriptions_.erase(it);
} else {
++it;
@@ -673,12 +629,6 @@ void MQTTClientComponent::disable() {
this->on_shutdown();
}
// Out of line so the topic buffer is off the stack while the matched callback runs
__attribute__((noinline)) static bool topic_matches(const char *message, const MQTTTopicSource &topic) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return topic_match(message, topic.build(buf).c_str());
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
#ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
@@ -712,7 +662,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string
const size_t count = this->subscriptions_.size();
for (size_t i = 0; i < count; i++) {
const auto &subscription = this->subscriptions_[i];
if (!subscription.removed && topic_matches(topic.c_str(), subscription.topic))
if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback.call(topic, payload);
}
this->dispatching_ = was_dispatching;
+13 -66
View File
@@ -8,11 +8,8 @@
#include "esphome/components/network/ip_address.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -25,7 +22,6 @@
#endif
#include "lwip/ip_addr.h"
#include <span>
#include <vector>
// The crash log replay needs the logger, which mqtt does not depend on
@@ -35,18 +31,6 @@
namespace esphome::mqtt {
// Max lengths for stack-based topic building.
// These limits are enforced at Python config validation time in mqtt/__init__.py
// using cv.Length() validators for topic_prefix.
// This ensures the stack buffers are always large enough.
static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20;
static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32;
static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Stack buffer size - safe because all inputs are length-validated at config time
// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null
static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN =
MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1;
/** Callback for MQTT events.
*/
using mqtt_on_connect_callback_t = std::function<MQTTBackend::on_connect_callback_t>;
@@ -62,33 +46,15 @@ using mqtt_json_callback_t ESPDEPRECATED(
"subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0",
"2026.11.0") = std::function<void(const std::string &, JsonObject)>;
/// Returns a subscription's topic, written into buf or referring to storage that outlives the subscription.
/// Never a null c_str(); an empty topic is "".
using mqtt_topic_builder_t = StringRef (*)(const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf);
/// Where a subscription's topic comes from. The topic is rebuilt whenever it is needed, so nothing is stored.
struct MQTTTopicSource {
mqtt_topic_builder_t builder;
const void *context;
StringRef build(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) const { return this->builder(this->context, buf); }
};
/// A topic string that outlives the subscription, such as a literal; it is referenced, not copied.
MQTTTopicSource static_topic(const char *topic);
#ifdef USE_ESP8266
/// A topic in flash, copied into the buffer when needed, so at most MQTT_DEFAULT_TOPIC_MAX_LEN - 1 characters.
MQTTTopicSource progmem_topic(ProgmemStr topic);
#endif
/// internal struct for MQTT subscriptions.
struct MQTTSubscription {
MQTTTopicSource topic;
mqtt_subscription_callback_t callback;
uint32_t resubscribe_timeout;
std::string topic;
uint8_t qos;
bool subscribed;
bool boxed; // made by create_boxed(), freed on unsubscribe
bool removed; // unsubscribed while a callback ran, erased once dispatch returns
mqtt_subscription_callback_t callback;
uint32_t resubscribe_timeout;
};
/// internal struct for MQTT credentials.
@@ -217,44 +183,32 @@ class MQTTClientComponent final : public Component {
* A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is
* boxed on the heap and freed by unsubscribe().
*
* @param topic Where the topic comes from; it is rebuilt when needed instead of stored.
* @param callback The callback function.
* @param qos The QoS of this subscription.
*/
template<typename F> void subscribe(MQTTTopicSource topic, F &&callback, uint8_t qos = 0) {
constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline<F>();
this->add_subscription_(topic,
boxed ? mqtt_subscription_callback_t::create_boxed(std::forward<F>(callback))
: mqtt_subscription_callback_t::create(std::forward<F>(callback)),
boxed, qos);
}
/** Subscribe to an MQTT topic and call callback when a message is received.
*
* The topic is copied to the heap and freed by unsubscribe().
*
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback function.
* @param qos The QoS of this subscription.
*/
template<typename F> void subscribe(const std::string &topic, F &&callback, uint8_t qos = 0) {
this->subscribe(copy_topic(StringRef(topic)), std::forward<F>(callback), qos);
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline<F>();
this->add_subscription_(std::move(topic),
boxed ? mqtt_subscription_callback_t::create_boxed(std::forward<F>(callback))
: mqtt_subscription_callback_t::create(std::forward<F>(callback)),
boxed, qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
* If an invalid JSON payload is received, the callback will not be called.
*
* @param topic An MQTTTopicSource, or a string that is copied to the heap.
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is
* received.
* @param qos The QoS of this subscription.
*/
template<typename T, typename F> void subscribe_json(T &&topic, F &&callback, uint8_t qos = 0) {
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
static_assert(std::is_invocable_v<const std::decay_t<F> &, const std::string &, JsonObject>,
"subscribe_json() needs a callback that does not mutate its captures");
this->subscribe(
std::forward<T>(topic),
std::move(topic),
[cb = std::forward<F>(callback)](const std::string &topic, const std::string &payload) {
call_json(cb, topic, payload);
},
@@ -347,12 +301,6 @@ class MQTTClientComponent final : public Component {
void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; }
protected:
// For the lambda command topic, which is copied once
friend class MQTTComponent;
/// A heap copy of topic, owned by the one subscription it is passed to and freed when that is erased.
static MQTTTopicSource copy_topic(StringRef topic);
void send_device_info_();
/// Reconnect to the MQTT broker if not already connected.
@@ -369,7 +317,6 @@ class MQTTClientComponent final : public Component {
void recalculate_availability_();
bool subscribe_(const char *topic, uint8_t qos);
bool subscribe_topic_(const MQTTTopicSource &topic, uint8_t qos);
void resubscribe_subscription_(MQTTSubscription *sub);
template<typename F> static void call_json(const F &callback, const std::string &topic, const std::string &payload) {
JsonDocument doc = json::parse_json(payload);
@@ -381,7 +328,7 @@ class MQTTClientComponent final : public Component {
#ifdef USE_MQTT_CRASH_LOG
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
#endif
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void resubscribe_subscriptions_();
MQTTCredentials credentials_;
+20 -23
View File
@@ -209,7 +209,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
}
void MQTTClimateComponent::setup() {
auto traits = this->device_->get_traits();
this->subscribe(this->get_mode_command_topic_source(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_mode(payload);
call.perform();
@@ -217,7 +217,7 @@ void MQTTClimateComponent::setup() {
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
this->subscribe(this->get_target_temperature_low_command_topic_source(),
this->subscribe(this->get_target_temperature_low_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -228,7 +228,7 @@ void MQTTClimateComponent::setup() {
call.set_target_temperature_low(*val);
call.perform();
});
this->subscribe(this->get_target_temperature_high_command_topic_source(),
this->subscribe(this->get_target_temperature_high_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -240,7 +240,7 @@ void MQTTClimateComponent::setup() {
call.perform();
});
} else {
this->subscribe(this->get_target_temperature_command_topic_source(),
this->subscribe(this->get_target_temperature_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -254,7 +254,7 @@ void MQTTClimateComponent::setup() {
}
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
this->subscribe(this->get_target_humidity_command_topic_source(),
this->subscribe(this->get_target_humidity_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_number<float>(payload);
if (!val.has_value()) {
@@ -268,30 +268,27 @@ void MQTTClimateComponent::setup() {
}
if (traits.get_supports_presets() || !traits.get_supported_custom_presets().empty()) {
this->subscribe(this->get_preset_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_preset(payload);
call.perform();
});
this->subscribe(this->get_preset_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_preset(payload);
call.perform();
});
}
if (traits.get_supports_fan_modes()) {
this->subscribe(this->get_fan_mode_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_fan_mode(payload);
call.perform();
});
this->subscribe(this->get_fan_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_fan_mode(payload);
call.perform();
});
}
if (traits.get_supports_swing_modes()) {
this->subscribe(this->get_swing_mode_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_swing_mode(payload);
call.perform();
});
this->subscribe(this->get_swing_mode_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto call = this->device_->make_call();
call.set_swing_mode(payload);
call.perform();
});
}
this->device_->add_on_state_callback([this](Climate & /*unused*/) { this->publish_state_(); });
@@ -124,14 +124,6 @@ StringRef MQTTComponent::get_command_topic_to_(std::span<char, MQTT_DEFAULT_TOPI
return this->get_default_topic_for_to_(buf, "command", 7);
}
MQTTTopicSource MQTTComponent::command_topic_source_() const {
if (this->custom_command_topic_.is_lambda()) {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
return MQTTClientComponent::copy_topic(this->get_command_topic_to_(buf));
}
return this->topic_source_<MQTTComponent, &MQTTComponent::get_command_topic_to_>();
}
std::string MQTTComponent::get_state_topic_() const {
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
StringRef ref = this->get_state_topic_to_(buf);
+20 -30
View File
@@ -21,14 +21,23 @@ struct SendDiscoveryConfig {
bool command_topic{true}; ///< If the command topic should be included. Default to true.
};
class MQTTComponent; // Forward declaration
// Validated in Python: cv.Length(max=64), DISCOVERY_PREFIX_MAX_LEN in mqtt/__init__.py
static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64;
// Max lengths for stack-based topic building.
// These limits are enforced at Python config validation time in mqtt/__init__.py
// using cv.Length() validators for topic_prefix and discovery_prefix.
// This ensures the stack buffers are always large enough.
static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20;
static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32;
static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Stack buffer size - safe because all inputs are length-validated at config time
// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null
static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN =
MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1;
static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null
static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN +
1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1;
class MQTTComponent; // Forward declaration
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic);
#define LOG_MQTT_COMPONENT(state_topic, command_topic) log_mqtt_component(TAG, this, state_topic, command_topic)
@@ -40,9 +49,7 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b
static_assert(sizeof(type_str) - 1 <= MQTT_COMPONENT_TYPE_MAX_LEN, \
#class_name "::component_type() exceeds MQTT_COMPONENT_TYPE_MAX_LEN");
// Macro to define custom topic getter/setter with compile-time suffix length verification.
// Set the topic before setup(): subscriptions rebuild it when needed, so a later change only reaches the broker
// after a reconnect.
// Macro to define custom topic getter/setter with compile-time suffix length verification
#define MQTT_COMPONENT_CUSTOM_TOPIC_(name, type) \
static_assert(sizeof(#name "/" #type) - 1 <= MQTT_SUFFIX_MAX_LEN, \
"topic suffix " #name "/" #type " exceeds MQTT_SUFFIX_MAX_LEN"); \
@@ -57,10 +64,6 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b
return StringRef(this->custom_##name##_##type##_topic_.data(), this->custom_##name##_##type##_topic_.size()); \
return this->get_default_topic_for_to_(buf, #name "/" #type, sizeof(#name "/" #type) - 1); \
} \
MQTTTopicSource get_##name##_##type##_topic_source() const { \
using Self = std::remove_cvref_t<decltype(*this)>; \
return this->topic_source_<Self, &Self::get_##name##_##type##_topic_to>(); \
} \
std::string get_##name##_##type##_topic() const { \
if (this->custom_##name##_##type##_topic_.empty()) \
return this->get_default_topic_for_(#name "/" #type); \
@@ -125,7 +128,6 @@ class MQTTComponent : public Component {
template<typename T> void set_custom_state_topic(T &&custom_state_topic) {
this->custom_state_topic_ = std::forward<T>(custom_state_topic);
}
/// Set a custom command topic before setup(); a later change only reaches the broker after a reconnect.
template<typename T> void set_custom_command_topic(T &&custom_command_topic) {
this->custom_command_topic_ = std::forward<T>(custom_command_topic);
}
@@ -253,25 +255,25 @@ class MQTTComponent : public Component {
/** Subscribe to a MQTT topic.
*
* @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap.
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback that will be called when a message with matching topic is received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
template<typename T, typename F> void subscribe(T &&topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe(std::forward<T>(topic), std::forward<F>(callback), qos);
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe(std::move(topic), std::forward<F>(callback), qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
* If an invalid JSON payload is received, the callback will not be called.
*
* @param topic The topic: an MQTTTopicSource, or a string that is copied to the heap.
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback with a parsed JsonObject that will be called when a message with matching topic is
* received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
template<typename T, typename F> void subscribe_json(T &&topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe_json(std::forward<T>(topic), std::forward<F>(callback), qos);
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe_json(std::move(topic), std::forward<F>(callback), qos);
}
protected:
@@ -320,18 +322,6 @@ class MQTTComponent : public Component {
/// @return StringRef pointing to the topic in the buffer.
StringRef get_command_topic_to_(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) const;
/// The command topic as a subscription source. A lambda topic is read once and copied, as it may change.
MQTTTopicSource command_topic_source_() const;
/// A topic built by one of this component's `get_*_topic_to` getters, rebuilt when needed.
template<typename C, StringRef (C::*Getter)(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN>) const>
MQTTTopicSource topic_source_() const {
return {[](const void *context, std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf) {
return (static_cast<const C *>(context)->*Getter)(buf);
},
static_cast<const C *>(this)};
}
/// Get the MQTT topic that new states will be shared to (allocates std::string).
std::string get_state_topic_() const;
+25 -27
View File
@@ -31,40 +31,38 @@ MQTTCoverComponent::MQTTCoverComponent(Cover *cover) : cover_(cover) {}
void MQTTCoverComponent::setup() {
auto traits = this->cover_->get_traits();
this->cover_->add_on_state_callback([this]() { this->publish_state(); });
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->cover_->make_call();
call.set_command(payload.c_str());
call.perform();
});
if (traits.get_supports_position()) {
this->subscribe(this->get_position_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
}
if (traits.get_supports_tilt()) {
this->subscribe(this->get_tilt_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) {
this->cover_->make_call().set_command_stop_tilt().perform();
return;
}
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_tilt(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) {
this->cover_->make_call().set_command_stop_tilt().perform();
return;
}
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
return;
}
auto call = this->cover_->make_call();
call.set_tilt(*value / 100.0f);
call.perform();
});
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTDateComponent::MQTTDateComponent(DateEntity *date) : date_(date) {}
void MQTTDateComponent::setup() {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->date_->make_call();
if (root[ESPHOME_F("year")].is<uint16_t>()) {
call.set_year(root[ESPHOME_F("year")]);
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTDateTimeComponent::MQTTDateTimeComponent(DateTimeEntity *datetime) : datetime_(datetime) {}
void MQTTDateTimeComponent::setup() {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->datetime_->make_call();
if (root[ESPHOME_F("year")].is<uint16_t>()) {
call.set_year(root[ESPHOME_F("year")]);
+26 -27
View File
@@ -28,7 +28,7 @@ MQTT_COMPONENT_TYPE(MQTTFanComponent, "fan")
const EntityBase *MQTTFanComponent::get_entity() const { return this->state_; }
void MQTTFanComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str());
switch (val) {
case PARSE_ON:
@@ -52,34 +52,33 @@ void MQTTFanComponent::setup() {
});
if (this->state_->get_traits().supports_direction()) {
this->subscribe(
this->get_direction_command_topic_source(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "forward", "reverse");
switch (val) {
case PARSE_ON:
ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform();
break;
case PARSE_OFF:
ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform();
break;
case PARSE_TOGGLE:
this->state_->make_call()
.set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE
: fan::FanDirection::FORWARD)
.perform();
break;
case PARSE_NONE:
ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str());
this->status_momentary_warning(5000);
break;
}
});
this->subscribe(this->get_direction_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "forward", "reverse");
switch (val) {
case PARSE_ON:
ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform();
break;
case PARSE_OFF:
ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_()));
this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform();
break;
case PARSE_TOGGLE:
this->state_->make_call()
.set_direction(this->state_->direction == fan::FanDirection::FORWARD ? fan::FanDirection::REVERSE
: fan::FanDirection::FORWARD)
.perform();
break;
case PARSE_NONE:
ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str());
this->status_momentary_warning(5000);
break;
}
});
}
if (this->state_->get_traits().supports_oscillation()) {
this->subscribe(this->get_oscillation_command_topic_source(),
this->subscribe(this->get_oscillation_command_topic(),
[this](const std::string &topic, const std::string &payload) {
auto val = parse_on_off(payload.c_str(), "oscillate_on", "oscillate_off");
switch (val) {
@@ -103,7 +102,7 @@ void MQTTFanComponent::setup() {
}
if (this->state_->get_traits().supports_speed()) {
this->subscribe(this->get_speed_level_command_topic_source(),
this->subscribe(this->get_speed_level_command_topic(),
[this](const std::string &topic, const std::string &payload) {
optional<int> speed_level_opt = parse_number<int>(payload);
if (speed_level_opt.has_value()) {
+1 -1
View File
@@ -18,7 +18,7 @@ MQTT_COMPONENT_TYPE(MQTTJSONLightComponent, "light")
const EntityBase *MQTTJSONLightComponent::get_entity() const { return this->state_; }
void MQTTJSONLightComponent::setup() {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
LightCall call = this->state_->make_call();
LightJSONSchema::parse_json(*this->state_, call, root);
call.perform();
+1 -1
View File
@@ -16,7 +16,7 @@ using namespace esphome::lock;
MQTTLockComponent::MQTTLockComponent(lock::Lock *a_lock) : lock_(a_lock) {}
void MQTTLockComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("LOCK")) == 0) {
this->lock_->lock();
} else if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("UNLOCK")) == 0) {
+1 -1
View File
@@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(NumberMqttModeStrings, "", "box", "slider");
MQTTNumberComponent::MQTTNumberComponent(Number *number) : number_(number) {}
void MQTTNumberComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
auto val = parse_number<float>(state);
if (!val.has_value()) {
ESP_LOGW(TAG, "Can't convert '%s' to number!", state.c_str());
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::select;
MQTTSelectComponent::MQTTSelectComponent(Select *select) : select_(select) {}
void MQTTSelectComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
auto call = this->select_->make_call();
call.set_option(state);
call.perform();
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::switch_;
MQTTSwitchComponent::MQTTSwitchComponent(switch_::Switch *a_switch) : switch_(a_switch) {}
void MQTTSwitchComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
switch (parse_on_off(payload.c_str())) {
case PARSE_ON:
this->switch_->turn_on();
+1 -1
View File
@@ -19,7 +19,7 @@ PROGMEM_STRING_TABLE(TextMqttModeStrings, "text", "password");
MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {}
void MQTTTextComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &state) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) {
auto call = this->text_->make_call();
call.set_value(state);
call.perform();
+1 -1
View File
@@ -17,7 +17,7 @@ using namespace esphome::datetime;
MQTTTimeComponent::MQTTTimeComponent(TimeEntity *time) : time_(time) {}
void MQTTTimeComponent::setup() {
this->subscribe_json(this->command_topic_source_(), [this](const std::string &topic, JsonObject root) {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->time_->make_call();
if (root[ESPHOME_F("hour")].is<uint8_t>()) {
call.set_hour(root[ESPHOME_F("hour")]);
+1 -1
View File
@@ -15,7 +15,7 @@ using namespace esphome::update;
MQTTUpdateComponent::MQTTUpdateComponent(UpdateEntity *update) : update_(update) {}
void MQTTUpdateComponent::setup() {
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
if (payload == "INSTALL") {
this->update_->perform();
} else {
+11 -12
View File
@@ -31,23 +31,22 @@ MQTTValveComponent::MQTTValveComponent(Valve *valve) : valve_(valve) {}
void MQTTValveComponent::setup() {
auto traits = this->valve_->get_traits();
this->valve_->add_on_state_callback([this]() { this->publish_state(); });
this->subscribe(this->command_topic_source_(), [this](const std::string &topic, const std::string &payload) {
this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) {
auto call = this->valve_->make_call();
call.set_command(payload.c_str());
call.perform();
});
if (traits.get_supports_position()) {
this->subscribe(this->get_position_command_topic_source(),
[this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->valve_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
this->subscribe(this->get_position_command_topic(), [this](const std::string &topic, const std::string &payload) {
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid position value: '%s'", payload.c_str());
return;
}
auto call = this->valve_->make_call();
call.set_position(*value / 100.0f);
call.perform();
});
}
}
+394 -39
View File
@@ -1,79 +1,226 @@
#include "rx8111.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::rx8111 {
ESPHOME_LOG_TAG(TAG, "rx8111");
// Indexed by the TSEL value stored in timer_clock_
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
static constexpr uint8_t RX8111_REG_SEC = 0x10;
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
static constexpr uint8_t RX8111_CONTROL_TIE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TSEL = 0x03;
static constexpr uint8_t RX8111_FLAG_TF = 0x10;
static constexpr uint8_t RX8111_FLAG_AF = 0x08;
static constexpr uint8_t RX8111_FLAG_POR = 0x80;
static constexpr uint8_t RX8111_FLAG_VLF = 0x02;
static constexpr uint8_t RX8111_FLAG_XST = 0x01;
static constexpr uint8_t RX8111_CTRL_STOP = 0x01;
// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write.
static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
void RX8111Component::setup() { this->read_time(); }
void RX8111Component::setup() {
this->configure_boot_alarm_();
this->configure_boot_timer_();
this->read_time();
if (this->alarm_enabled_ || this->watching_()) {
this->start_event_polling_();
}
}
void RX8111Component::update() { this->read_time(); }
void RX8111Component::configure_boot_alarm_() {
bool success = true;
if (this->alarm_enabled_) {
if (this->alarm_second_ != ALARM_FIELD_UNSET || this->alarm_minute_ != ALARM_FIELD_UNSET ||
this->alarm_hour_ != ALARM_FIELD_UNSET || this->alarm_weekday_ != ALARM_FIELD_UNSET ||
this->alarm_day_ != ALARM_FIELD_UNSET) {
success = this->program_alarm_(this->alarm_second_, this->alarm_minute_, this->alarm_hour_, this->alarm_weekday_,
this->alarm_day_, true);
if (success) {
// Only the first successful boot program can introduce a boot wake-up edge.
if (!this->boot_alarm_event_seen_)
this->alarm_flag_seen_ = false;
this->start_event_polling_();
}
} else {
// Preserve an alarm programmed by an action before a reboot or deep sleep.
uint8_t control;
success = this->read_byte(RX8111_REG_CONTROL, &control);
if (success) {
this->alarm_armed_ = (control & RX8111_CONTROL_AIE) != 0;
if (this->alarm_armed_)
this->start_event_polling_();
}
}
} else if (this->alarm_explicitly_configured_) {
success = this->set_alarm_interrupt_(false);
}
this->boot_config_result_(true, success);
}
void RX8111Component::configure_boot_timer_() {
bool success = true;
// An omitted timer setting leaves battery-backed state alone; explicit false stops it.
if (this->timer_configured_) {
const bool start_timer = this->timer_enabled_;
if (start_timer) {
success = this->configure_timer_();
} else {
uint8_t extension, control;
success = this->disable_timer_(extension, control);
}
if (success && !start_timer && this->timer_count_ != 0) {
this->timer_retained_event_pending_ = true;
this->start_event_polling_();
}
}
this->boot_config_result_(false, success);
}
void RX8111Component::boot_config_result_(bool alarm, bool success) {
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
++attempts;
if (success) {
this->clear_warning_(warning);
return;
}
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
return;
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
[this, alarm]() {
if (alarm) {
this->configure_boot_alarm_();
} else {
this->configure_boot_timer_();
}
});
}
// A runtime action supersedes pending boot programming of the same feature
void RX8111Component::cancel_boot_retry_(bool alarm) {
this->cancel_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID);
}
void RX8111Component::check_event_flags_() {
if (this->watching_() && !this->poll_event_flags_())
this->set_warning_(WARN_EVENT_POLL, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
// A callback or a runtime action may have stopped the last watched feature
if (!this->watching_() && this->event_polling_started_) {
this->cancel_interval(RX8111_EVENT_INTERVAL_ID);
this->event_polling_started_ = false;
}
}
void RX8111Component::start_event_polling_() {
if (this->event_polling_started_)
return;
this->set_interval(RX8111_EVENT_INTERVAL_ID, this->event_poll_interval_, [this]() { this->check_event_flags_(); });
this->event_polling_started_ = true;
}
void RX8111Component::update() {
this->read_time();
this->check_event_flags_();
}
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
this->warnings_ |= bit;
this->status_set_warning(message);
}
void RX8111Component::clear_warning_(uint8_t bit) {
if ((this->warnings_ & bit) == 0)
return;
this->warnings_ &= ~bit;
if (this->warnings_ == 0)
this->status_clear_warning();
}
void RX8111Component::dump_config() {
ESP_LOGCONFIG(TAG, "RX8111:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG,
" Alarm interrupt: %s\n"
" Wake-up timer: %s",
ONOFF(this->alarm_enabled_), ONOFF(this->timer_enabled_));
if (this->timer_enabled_) {
ESP_LOGCONFIG(TAG, " Count: %" PRIu32 ", clock: %s", this->timer_count_,
LOG_STR_ARG(TimerClockNames::get_log_str(this->timer_clock_, TimerClockNames::LAST_INDEX)));
}
RealTimeClock::dump_config();
}
void RX8111Component::read_time() {
ESPTime rtc_time;
if (!this->read_calendar_(rtc_time))
return;
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
}
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time"));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
return false;
}
uint8_t buf[7];
// I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot.
if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
bool valid = true;
for (size_t i = 0; i < sizeof(buf); i++) {
if (i == 3)
continue; // Weekday uses one-hot encoding, not BCD.
buf[i] &= CALENDAR_MASKS[i];
if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) {
ESP_LOGW(TAG, "Invalid BCD in RTC time");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
}
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
}
const uint8_t onehot = buf[3];
if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) {
ESP_LOGW(TAG, "Invalid RTC weekday");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
valid = valid && onehot != 0 && onehot <= 0x40 && (onehot & (onehot - 1)) == 0;
if (valid) {
rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
valid = rtc_time.is_valid(true, false) && rtc_time.second < 60 &&
ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week;
}
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
if (!valid) {
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR("Invalid RTC time"));
return false;
}
if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week,
LOG_STR("Invalid RTC time"))) {
ESP_LOGW(TAG, "RTC weekday does not match calendar date");
return;
}
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
this->clear_warning_(WARN_CALENDAR);
return true;
}
void RX8111Component::write_time() {
@@ -83,7 +230,7 @@ void RX8111Component::write_time() {
return;
}
if (!this->set_stop_bit_(true)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
@@ -93,25 +240,233 @@ void RX8111Component::write_time() {
// Always try to restart the counter, even after a failed calendar write.
const bool restarted = this->set_stop_bit_(false);
if (!written || !restarted) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)))
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->clear_warning_(WARN_WRITE_TIME);
this->clear_warning_(WARN_CALENDAR);
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
bool RX8111Component::set_stop_bit_(bool stop) {
uint8_t control;
if (!this->read_byte(RX8111_REG_CONTROL, &control))
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
}
bool RX8111Component::set_alarm_interrupt_(bool enabled) {
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_AIE, enabled ? RX8111_CONTROL_AIE : 0);
}
bool RX8111Component::program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day,
bool boot) {
uint8_t extension;
if (!this->read_byte(RX8111_REG_EXTENSION, &extension))
return false;
if (stop) {
control |= RX8111_CTRL_STOP;
} else {
control &= ~RX8111_CTRL_STOP;
if (weekday != ALARM_FIELD_UNSET) {
extension &= ~RX8111_EXTENSION_WADA;
} else if (day != ALARM_FIELD_UNSET) {
extension |= RX8111_EXTENSION_WADA;
}
return this->write_byte(RX8111_REG_CONTROL, control);
const uint8_t fields[] = {
second == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(second),
minute == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(minute),
hour == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(hour),
weekday != ALARM_FIELD_UNSET ? static_cast<uint8_t>(1U << (weekday - 1))
: day != ALARM_FIELD_UNSET ? uint8_to_bcd(day)
: RX8111_ALARM_DISABLED,
};
// Disable the interrupt during partial programming; keep AF (past events) latched.
if (!this->set_alarm_interrupt_(false))
return false;
this->alarm_armed_ = false;
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
if (!boot)
this->alarm_flag_seen_ = (flags & RX8111_FLAG_AF) != 0;
if (!this->write_byte(RX8111_REG_SEC_ALARM, fields[0]) || !this->write_bytes(RX8111_REG_MIN_ALARM, &fields[1], 3) ||
!this->write_byte(RX8111_REG_EXTENSION, extension))
return false;
if (!this->set_alarm_interrupt_(this->alarm_enabled_))
return false;
this->alarm_armed_ = true;
return true;
}
bool RX8111Component::set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day) {
if (!this->alarm_enabled_) {
ESP_LOGE(TAG, "Alarm interrupt disabled; set alarm_enabled: true");
return false;
}
constexpr uint16_t unset = ALARM_FIELD_UNSET;
if ((second != unset && second > 59) || (minute != unset && minute > 59) || (hour != unset && hour > 23) ||
(weekday != unset && (weekday < 1 || weekday > 7)) || (day != unset && (day < 1 || day > 31)) ||
(weekday != unset && day != unset) ||
(second == unset && minute == unset && hour == unset && weekday == unset && day == unset)) {
ESP_LOGE(TAG, "Invalid alarm fields");
return false;
}
this->cancel_boot_retry_(true);
if (!this->program_alarm_(second, minute, hour, weekday, day)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
this->boot_alarm_event_seen_ = true;
this->start_event_polling_();
return true;
}
bool RX8111Component::schedule_alarm_in(uint32_t seconds) {
if (!this->alarm_enabled_ || seconds == 0 || seconds >= 28U * 86400U) {
ESP_LOGE(TAG, "Relative alarm requires alarm_enabled and a duration of 1 second to less than 28 days");
return false;
}
ESPTime now;
if (!this->read_calendar_(now)) {
ESP_LOGE(TAG, "Cannot read valid RTC time for relative alarm");
return false;
}
auto target = ESPTime::from_epoch_utc(now.timestamp + seconds);
if (!target.is_valid() || target.year > 2099) {
ESP_LOGE(TAG, "Relative alarm target is outside RTC year range");
return false;
}
return this->set_alarm(target.second, target.minute, target.hour, ALARM_FIELD_UNSET, target.day_of_month);
}
bool RX8111Component::unset_alarm() {
this->cancel_boot_retry_(true);
if (!this->set_alarm_interrupt_(false)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_armed_ = false;
// Minute, hour and day alarm registers are contiguous
static constexpr uint8_t DISABLED_FIELDS[3] = {RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED};
if (!this->write_byte(RX8111_REG_SEC_ALARM, RX8111_ALARM_DISABLED) ||
!this->write_bytes(RX8111_REG_MIN_ALARM, DISABLED_FIELDS, sizeof(DISABLED_FIELDS))) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
return true;
}
bool RX8111Component::clear_alarm_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_AF))) {
this->set_warning_(WARN_ALARM_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_flag_seen_ = false;
this->clear_warning_(WARN_ALARM_FLAG_CLEAR);
return true;
}
// Clears TE and TIE; returns the register values read so callers can build on them
bool RX8111Component::disable_timer_(uint8_t &extension, uint8_t &control) {
return this->read_byte(RX8111_REG_EXTENSION, &extension) &&
this->write_byte(RX8111_REG_EXTENSION, extension & ~RX8111_EXTENSION_TE) &&
this->read_byte(RX8111_REG_CONTROL, &control) &&
this->write_byte(RX8111_REG_CONTROL, control & ~RX8111_CONTROL_TIE);
}
bool RX8111Component::configure_timer_(bool clear_stale_flag) {
// Stop counting before updating the 24-bit reload value; preserve the boot flag unless explicitly starting.
uint8_t extension;
uint8_t control;
if (!this->disable_timer_(extension, control))
return false;
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
static_cast<uint8_t>(this->timer_count_ >> 16)};
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
!this->write_byte(RX8111_REG_EXTENSION,
(extension & ~(RX8111_EXTENSION_TE | RX8111_EXTENSION_TSEL)) | this->timer_clock_) ||
(clear_stale_flag && !this->clear_timer_flag()) ||
!this->write_byte(RX8111_REG_CONTROL, control | RX8111_CONTROL_TIE))
return false;
return this->write_byte(RX8111_REG_EXTENSION,
(extension & ~RX8111_EXTENSION_TSEL) | this->timer_clock_ | RX8111_EXTENSION_TE);
}
bool RX8111Component::start_timer() {
if (this->timer_count_ == 0 || this->timer_count_ > 0xFFFFFF) {
ESP_LOGW(TAG, "Cannot start timer without a valid timer_count");
return false;
}
this->cancel_boot_retry_(false);
if (!this->configure_timer_(true)) {
// A partial configuration can leave either enable bit set. Disable both if the bus permits it;
// otherwise keep watching a timer that may still be running.
uint8_t extension, control;
if (this->disable_timer_(extension, control))
this->timer_enabled_ = false;
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_TIMER);
this->timer_enabled_ = true;
this->timer_retained_event_pending_ = false;
this->start_event_polling_();
return true;
}
bool RX8111Component::clear_timer_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_TF))) {
this->set_warning_(WARN_TIMER_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_flag_seen_ = false;
this->timer_retained_event_pending_ = false;
this->clear_warning_(WARN_TIMER_FLAG_CLEAR);
return true;
}
bool RX8111Component::stop_timer() {
this->cancel_boot_retry_(false);
uint8_t extension, control;
if (!this->disable_timer_(extension, control)) {
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_enabled_ = false;
this->clear_warning_(WARN_TIMER);
return true;
}
bool RX8111Component::poll_event_flags_() {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
this->clear_warning_(WARN_EVENT_POLL);
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
// alarm_armed_ is only ever set while the alarm is enabled
const bool timer_watched = this->timer_enabled_ || this->timer_retained_event_pending_;
if (alarm_active && this->alarm_armed_)
this->boot_alarm_event_seen_ = true;
const bool alarm_rising = alarm_active && !this->alarm_flag_seen_ && this->alarm_armed_;
const bool timer_rising = timer_active && !this->timer_flag_seen_ && timer_watched;
const bool deliver_stopped_event = timer_rising && this->timer_retained_event_pending_;
// Record eligible edges before callbacks, which may clear flags synchronously.
// Leave flags unseen while boot configuration is still waiting to enable delivery.
if (this->alarm_armed_)
this->alarm_flag_seen_ = alarm_active;
if (timer_watched)
this->timer_flag_seen_ = timer_active;
this->timer_retained_event_pending_ = false;
if (alarm_rising)
this->alarm_callbacks_.call();
// An alarm callback may stop the timer before its callback is delivered.
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
this->timer_callbacks_.call();
return true;
}
} // namespace esphome::rx8111
+87
View File
@@ -1,10 +1,15 @@
#pragma once
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/time/real_time_clock.h"
namespace esphome::rx8111 {
namespace testing {
class RX8111Test;
} // namespace testing
class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice {
public:
void setup() override;
@@ -12,9 +17,91 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
void dump_config() override;
void read_time();
void write_time();
void set_alarm_enabled(bool enabled) { this->alarm_enabled_ = enabled; }
void set_alarm_explicitly_configured(bool configured) { this->alarm_explicitly_configured_ = configured; }
void set_alarm_second(uint8_t value) { this->alarm_second_ = value; }
void set_alarm_minute(uint8_t value) { this->alarm_minute_ = value; }
void set_alarm_hour(uint8_t value) { this->alarm_hour_ = value; }
void set_alarm_weekday(uint8_t value) { this->alarm_weekday_ = value; }
void set_alarm_day(uint8_t value) { this->alarm_day_ = value; }
void set_timer_count(uint32_t count) { this->timer_count_ = count; }
void set_timer_clock(uint8_t clock) { this->timer_clock_ = clock; }
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callbacks_.add(std::forward<F>(callback));
}
bool set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day);
bool schedule_alarm_in(uint32_t seconds);
bool unset_alarm();
bool clear_alarm_flag();
bool clear_timer_flag();
bool start_timer();
bool stop_timer();
template<typename F> void add_on_timer_callback(F &&callback) {
this->timer_callbacks_.add(std::forward<F>(callback));
}
protected:
friend class testing::RX8111Test;
static constexpr uint32_t BOOT_RETRY_DELAY_MS = 1000;
// set_alarm() and the boot alarm ignore a field set to this value
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
// One bit per fault source, so a recovery in one path cannot hide another path's fault
enum Warning : uint8_t {
WARN_CALENDAR = 1 << 0,
WARN_EVENT_POLL = 1 << 1,
WARN_ALARM = 1 << 2,
WARN_TIMER = 1 << 3,
WARN_WRITE_TIME = 1 << 4,
WARN_ALARM_FLAG_CLEAR = 1 << 5,
WARN_TIMER_FLAG_CLEAR = 1 << 6,
};
bool set_stop_bit_(bool stop);
bool read_calendar_(ESPTime &rtc_time);
void set_warning_(uint8_t bit, const LogString *message);
void clear_warning_(uint8_t bit);
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
void check_event_flags_();
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
bool set_alarm_interrupt_(bool enabled);
bool disable_timer_(uint8_t &extension, uint8_t &control);
bool configure_timer_(bool clear_stale_flag = false);
void start_event_polling_();
bool poll_event_flags_();
void configure_boot_alarm_();
void configure_boot_timer_();
void boot_config_result_(bool alarm, bool success);
void cancel_boot_retry_(bool alarm);
LazyCallbackManager<void()> alarm_callbacks_;
LazyCallbackManager<void()> timer_callbacks_;
uint32_t timer_count_{0};
uint32_t event_poll_interval_{1000};
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
uint8_t warnings_{0};
uint8_t timer_clock_{2}; // 1 Hz
uint8_t boot_alarm_attempts_{0};
uint8_t boot_timer_attempts_{0};
uint8_t alarm_second_{ALARM_FIELD_UNSET};
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
uint8_t alarm_day_{ALARM_FIELD_UNSET};
bool timer_enabled_{false};
bool timer_configured_{false};
bool timer_flag_seen_{false};
bool timer_retained_event_pending_{false};
bool event_polling_started_{false};
bool boot_alarm_event_seen_{false};
bool alarm_enabled_{false};
bool alarm_explicitly_configured_{false};
bool alarm_flag_seen_{false};
bool alarm_armed_{false};
};
} // namespace esphome::rx8111
+177 -6
View File
@@ -4,6 +4,14 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, time
import esphome.config_validation as cv
from esphome.const import (
CONF_DAY,
CONF_DURATION,
CONF_HOUR,
CONF_ID,
CONF_MINUTE,
CONF_SECOND,
)
from esphome.types import ConfigType
CODEOWNERS = ["@techdregs"]
@@ -11,23 +19,186 @@ DEPENDENCIES = ["i2c"]
rx8111_ns = cg.esphome_ns.namespace("rx8111")
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
# set_alarm() ignores a field set to this value
ALARM_FIELD_UNSET = 0xFF
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{cv.GenerateID(): cv.declare_id(RX8111Component)}
).extend(i2c.i2c_device_schema(0x32))
CONF_ALARM_SECOND = "alarm_second"
CONF_ALARM_MINUTE = "alarm_minute"
CONF_ALARM_HOUR = "alarm_hour"
CONF_ALARM_WEEKDAY = "alarm_weekday"
CONF_ALARM_DAY = "alarm_day"
CONF_ALARM_ENABLED = "alarm_enabled"
CONF_ON_ALARM = "on_alarm"
CONF_TIMER_COUNT = "timer_count"
CONF_TIMER_CLOCK = "timer_clock"
CONF_TIMER_ENABLED = "timer_enabled"
CONF_ON_TIMER = "on_timer"
CONF_WEEKDAY = "weekday"
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
_ALARM_FIELDS = (
CONF_ALARM_SECOND,
CONF_ALARM_MINUTE,
CONF_ALARM_HOUR,
CONF_ALARM_WEEKDAY,
CONF_ALARM_DAY,
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
)
def _validate_alarm_config(config: ConfigType) -> ConfigType:
"""Rules that depend on the derived alarm_enabled and timer_enabled defaults."""
if CONF_ON_ALARM in config and not config.get(
CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS)
):
raise cv.Invalid(
"on_alarm requires alarm_enabled: true or alarm matching fields"
)
if CONF_TIMER_COUNT not in config and (
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
):
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
return config
_validate_alarm_options = cv.All(
cv.has_at_most_one_key(CONF_ALARM_DAY, CONF_ALARM_WEEKDAY), _validate_alarm_config
)
CONFIG_SCHEMA = cv.All(
time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(RX8111Component),
cv.Optional(CONF_ALARM_SECOND): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_MINUTE): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_HOUR): cv.int_range(min=0, max=23),
cv.Optional(CONF_ALARM_WEEKDAY): cv.int_range(min=1, max=7),
cv.Optional(CONF_ALARM_DAY): cv.int_range(min=1, max=31),
cv.Optional(CONF_ALARM_ENABLED): cv.boolean,
cv.Optional(CONF_ON_ALARM): automation.validate_automation({}),
cv.Optional(CONF_TIMER_COUNT): cv.int_range(min=1, max=0xFFFFFF),
cv.Optional(CONF_TIMER_CLOCK, default="1hz"): cv.enum(
TIMER_CLOCKS, lower=True
),
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
# Below 500 ms a scheduler interval costs more than it saves
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=500)),
),
}
).extend(i2c.i2c_device_schema(0x32)),
_validate_alarm_options,
)
RX8111_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(RX8111Component)}
)
automation.register_apply_action(
"rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()")
# Actions that call one argument-less method on the component
for _method in (
"write_time",
"read_time",
"clear_alarm_flag",
"clear_timer_flag",
"unset_alarm",
"stop_timer",
"start_timer",
):
automation.register_apply_action(
f"rx8111.{_method}", RX8111_ACTION_SCHEMA, automation.ApplyCall(f"{_method}()")
)
_SET_ALARM_FIELDS = (CONF_SECOND, CONF_MINUTE, CONF_HOUR, CONF_WEEKDAY, CONF_DAY)
def _fill_unset_alarm_fields(config: ConfigType) -> ConfigType:
"""Marks the fields not given as unset, so set_alarm() always gets all five."""
for field in _SET_ALARM_FIELDS:
config.setdefault(field, ALARM_FIELD_UNSET)
return config
_validate_set_alarm = cv.All(
cv.has_at_least_one_key(*_SET_ALARM_FIELDS),
cv.has_at_most_one_key(CONF_WEEKDAY, CONF_DAY),
_fill_unset_alarm_fields,
)
automation.register_apply_action(
"rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()")
"rx8111.set_alarm",
cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Optional(CONF_SECOND): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_MINUTE): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_HOUR): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_WEEKDAY): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_DAY): cv.templatable(cv.int_range(min=1, max=31)),
}
),
_validate_set_alarm,
),
automation.ApplyCall(
"set_alarm({}, {}, {}, {}, {})",
(
(CONF_SECOND, cg.uint16),
(CONF_MINUTE, cg.uint16),
(CONF_HOUR, cg.uint16),
(CONF_WEEKDAY, cg.uint16),
(CONF_DAY, cg.uint16),
),
),
)
# The alarm registers hold a day of month, so the target must stay inside the current month cycle
_ALARM_DURATION = cv.All(
cv.positive_time_period_seconds,
cv.Range(
min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(days=28), max_included=False
),
)
automation.register_apply_action(
"rx8111.schedule_alarm_in",
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Required(CONF_DURATION): cv.templatable(_ALARM_DURATION),
}
),
automation.ApplyField(CONF_DURATION, "schedule_alarm_in", cg.uint32),
)
async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await time.register_time(var, config)
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
cg.add_optional(var.set_alarm_weekday, config.get(CONF_ALARM_WEEKDAY))
cg.add_optional(var.set_alarm_day, config.get(CONF_ALARM_DAY))
cg.add(
var.set_alarm_enabled(
config.get(CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS))
)
)
cg.add(var.set_alarm_explicitly_configured(CONF_ALARM_ENABLED in config))
if (count := config.get(CONF_TIMER_COUNT)) is not None:
cg.add(var.set_timer_count(count))
cg.add(var.set_timer_clock(config[CONF_TIMER_CLOCK]))
# Validation guarantees on_timer comes with a timer_count
cg.add(var.set_timer_enabled(config.get(CONF_TIMER_ENABLED, count is not None)))
cg.add(var.set_timer_configured(CONF_TIMER_ENABLED in config or count is not None))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+52 -3
View File
@@ -1,3 +1,5 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -9,6 +11,7 @@ from esphome.const import (
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
@@ -17,12 +20,15 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_HOUR,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MILLIVOLT,
UNIT_MINUTE,
UNIT_SECOND,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
@@ -33,6 +39,8 @@ from esphome.types import ConfigType
from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns
_LOGGER = logging.getLogger(__name__)
TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component)
@@ -59,6 +67,9 @@ _APPARENT_POWER_KVA = _preset(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT)
_DURATION_MIN = _preset(UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT)
# Numeric values without a physical unit (indexes, bit fields, versions)
_NO_UNIT: dict[str, str] = {}
# Legacy defaults, kept for tags without a known preset
_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH}
@@ -75,6 +86,7 @@ _COMPATIBLE_UNITS = {
DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT},
DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS},
DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT},
DEVICE_CLASS_DURATION: {UNIT_SECOND, UNIT_MINUTE, UNIT_HOUR},
}
# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant.
@@ -90,6 +102,10 @@ TIC_TAG_CONFIGS = {
"CC": _POWER,
"PREF": _APPARENT_POWER_KVA,
"PCOUP": _APPARENT_POWER_KVA,
"VTIC": _NO_UNIT,
"NTARF": _NO_UNIT,
"NJOURF": _NO_UNIT,
"RELAIS": _NO_UNIT,
# Historical mode
"BASE": _ENERGY,
"HCH": _ENERGY,
@@ -101,15 +117,42 @@ TIC_TAG_CONFIGS = {
"ADPS": _CURRENT,
"ADIR": _CURRENT,
"PAPP": _APPARENT_POWER_VA,
"PMAX": _POWER,
"PMAX": _APPARENT_POWER_VA,
"PEJP": _DURATION_MIN,
}
# Prefixes of text tags, which a sensor cannot publish; a text_sensor should be used
# instead. They get no preset, and no prefix here is a prefix of a preset one either.
TIC_TEXT_TAGS = (
# Standard mode
"ADSC",
"DATE",
"NGTF",
"LTARF",
"STGE",
"DPM",
"FPM",
"MSG",
"PRM",
"PJOURF",
"PPOINTE",
# Historical mode
"ADCO",
"OPTARIF",
"PTEC",
"DEMAIN",
"HHPHC",
"MOTDETAT",
"PPOT",
)
def apply_tag_config(config: ConfigType) -> ConfigType:
"""Apply preset configurations based on the tag name.
Only keys not set by the user are filled in. For known tags, a preset unit or
device class is dropped if it is incompatible with the one set by the user.
Known text tags get no preset and log a warning, as a sensor cannot publish them.
Unknown tags get the legacy defaults, merged key by key as before.
"""
if CONF_TAG_NAME not in config:
@@ -117,6 +160,12 @@ def apply_tag_config(config: ConfigType) -> ConfigType:
# Runs before cv.string, so the raw value may not be a string yet
tag_name = str(config[CONF_TAG_NAME])
if tag_name.startswith(TIC_TEXT_TAGS):
_LOGGER.warning(
"TeleInfo tag '%s' holds a text value, use a text_sensor instead", tag_name
)
return config
preset = next(
(p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)),
None,
@@ -128,8 +177,8 @@ def apply_tag_config(config: ConfigType) -> ConfigType:
config.setdefault(key, value)
return config
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset.get(CONF_UNIT_OF_MEASUREMENT))
device_class = config.get(CONF_DEVICE_CLASS, preset.get(CONF_DEVICE_CLASS))
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
for key, value in preset.items():
+40 -78
View File
@@ -2,95 +2,57 @@
namespace esphome::unicode {
uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
// Safely cast to uint8_t* for correct bitwise operations on bytes
const uint8_t *current = reinterpret_cast<const uint8_t *>(utf8_str);
uint32_t code_point = 0;
uint8_t c1 = *current++;
// Reads count continuation bytes into code_point, stopping at the first byte that is not one so a
// sequence cut short by the terminator is never read past
static bool read_continuation(const uint8_t *&current, uint8_t count, uint32_t &code_point) {
for (uint8_t i = 0; i < count; i++) {
const uint8_t c = *current;
if ((c & 0xC0) != 0x80) {
return false;
}
current++;
code_point = (code_point << 6) | (c & 0x3F);
}
return true;
}
// check for end of string
uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
const auto *start = reinterpret_cast<const uint8_t *>(utf8_str);
const uint8_t *current = start;
const uint8_t c1 = *current++;
uint8_t count;
uint32_t code_point;
uint32_t lowest; // the smallest code point this sequence length may encode; shorter forms are overlong
if (c1 == 0) {
*length = 0;
return 0;
}
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
if (c1 < 0x80) {
// Valid ASCII byte.
count = 0;
code_point = c1;
// Optimization: No need to check for continuation bytes.
lowest = 0;
} else if ((c1 & 0xE0) == 0xC0) {
count = 1;
code_point = c1 & 0x1F;
lowest = 0x80;
} else if ((c1 & 0xF0) == 0xE0) {
count = 2;
code_point = c1 & 0x0F;
lowest = 0x800;
} else if ((c1 & 0xF8) == 0xF0) {
count = 3;
code_point = c1 & 0x07;
lowest = 0x10000;
} else {
*length = 0; // a continuation byte or an invalid lead byte
return 0;
}
// --- 2-Byte Sequence: 110xxxxx 10xxxxxx ---
else if ((c1 & 0xE0) == 0xC0) {
uint8_t c2 = *current++;
// Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx)
if ((c2 & 0xC0) != 0x80) {
*length = 0;
return 0;
}
code_point = (c1 & 0x1F) << 6;
code_point |= (c2 & 0x3F);
// Error Check 2: Overlong check (2-byte must be > 0x7F)
if (code_point <= 0x7F) {
*length = 0;
return 0;
}
}
// --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF0) == 0xE0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x0F) << 12;
code_point |= (c2 & 0x3F) << 6;
code_point |= (c3 & 0x3F);
// Error Check 2: Overlong check (3-byte must be > 0x7FF)
// Also check for surrogates (0xD800-0xDFFF)
if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
}
// --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF8) == 0xF0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
uint8_t c4 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x07) << 18;
code_point |= (c2 & 0x3F) << 12;
code_point |= (c3 & 0x3F) << 6;
code_point |= (c4 & 0x3F);
// Error Check 2: Overlong check (4-byte must be > 0xFFFF)
// Also check for valid Unicode range (must be <= 0x10FFFF)
if (code_point <= 0xFFFF || code_point > 0x10FFFF) {
*length = 0;
return 0;
}
}
// --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) ---
else {
if (!read_continuation(current, count, code_point) || code_point < lowest || code_point > 0x10FFFF ||
(code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
*length = current - start;
return code_point;
}
+14
View File
@@ -29,9 +29,15 @@ MULTI_CONF = True
CONF_MAX_UPDATE_DELAY = "max_update_delay"
CONF_UPDATE_DELAY = "update_delay"
CONF_PIXEL_FORMAT = "pixel_format"
vnc_ns = cg.esphome_ns.namespace("vnc")
VncClient = vnc_ns.class_("VncClient", cg.Component)
VncPixelFormat = vnc_ns.enum("VncPixelFormat", is_class=True)
PIXEL_FORMATS = VncPixelFormat.options(
"auto", "rgb565", "rgb332", prefix="VNC_PIXEL_FORMAT_"
)
# Drivers that cannot take another component's pixels yet.
UNSUPPORTED_DISPLAYS = dict.fromkeys(
@@ -63,6 +69,9 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_MAX_UPDATE_DELAY, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_PIXEL_FORMAT, default="auto"): cv.enum(
PIXEL_FORMATS, lower=True
),
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
@@ -128,6 +137,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
cg.add(var.set_pixel_format(config[CONF_PIXEL_FORMAT]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -135,4 +145,8 @@ VNC_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(VncClient)},
)
automation.register_apply_action(
"vnc.refresh", VNC_ID_SCHEMA, automation.ApplyCall("refresh()")
)
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
+296 -11
View File
@@ -4,10 +4,12 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
#include <iterator>
namespace esphome::vnc {
@@ -32,7 +34,19 @@ static constexpr uint8_t SECURITY_NONE = 1;
static constexpr uint8_t SECURITY_VNC_AUTH = 2;
static constexpr int32_t ENCODING_RAW = 0;
static constexpr int32_t ENCODING_HEXTILE = 5;
static constexpr int32_t ENCODING_RICH_CURSOR = -239;
// In preference order: TightVNC uses the first real encoding it supports for every rectangle.
static constexpr int32_t ENCODINGS[] = {ENCODING_HEXTILE, ENCODING_RAW, ENCODING_RICH_CURSOR};
// Indexed by VncPixelFormat
PROGMEM_STRING_TABLE(PixelFormatStrings, "auto", "RGB565", "RGB332");
static constexpr uint8_t HEXTILE_RAW = 0x01;
static constexpr uint8_t HEXTILE_BACKGROUND = 0x02;
static constexpr uint8_t HEXTILE_FOREGROUND = 0x04;
static constexpr uint8_t HEXTILE_ANY_SUBRECTS = 0x08;
static constexpr uint8_t HEXTILE_SUBRECTS_COLOURED = 0x10;
static constexpr uint8_t MSG_FRAMEBUFFER_UPDATE = 0;
static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
@@ -66,6 +80,32 @@ static int parse_three_digits(const uint8_t *p) {
void VncClient::setup() {
this->setup_ms_ = App.get_loop_component_start_time();
this->link_.begin(TAG);
// Sized for the display now; a rotation changed later swaps the sides but keeps the cell count.
const uint32_t wanted = static_cast<uint32_t>((this->display_->get_width() + 15) / 16) *
static_cast<uint32_t>((this->display_->get_height() + 15) / 16);
const uint32_t cells = std::min<uint32_t>(wanted, UINT16_MAX);
if (cells < wanted) {
ESP_LOGW(TAG, "Display has %" PRIu32 " tiles, more than the %u the cache holds", wanted, UINT16_MAX);
}
RAMAllocator<uint32_t> allocator;
this->tile_hashes_ = allocator.allocate(cells);
if (this->tile_hashes_ == nullptr) {
ESP_LOGW(TAG, "Not enough memory to skip unchanged tiles");
return;
}
this->hash_capacity_ = cells;
this->clear_tiles_();
}
void VncClient::refresh() {
this->need_full_ = true;
this->clear_tiles_();
}
void VncClient::clear_tiles_() {
if (this->tile_hashes_ != nullptr) {
std::memset(this->tile_hashes_, 0, sizeof(uint32_t) * this->hash_capacity_);
}
}
void VncClient::dump_config() {
@@ -74,9 +114,13 @@ void VncClient::dump_config() {
" Server: %s:%u\n"
" Reconnect Interval: %" PRIu32 "ms\n"
" Update Delay: %" PRIu32 "ms\n"
" Max Update Delay: %" PRIu32 "ms",
" Max Update Delay: %" PRIu32 "ms\n"
" Pixel Format: %s\n"
" Tile Cache: %u tiles",
this->link_.host(), this->link_.port(), this->link_.reconnect_interval(), this->update_delay_ms_,
this->max_update_delay_ms_);
this->max_update_delay_ms_,
LOG_STR_ARG(PixelFormatStrings::get_log_str(static_cast<uint8_t>(this->pixel_format_), 0)),
this->hash_capacity_);
}
void VncClient::on_shutdown() { this->link_.close(); }
@@ -95,12 +139,28 @@ void VncClient::loop() {
}
// A display that parks its loop when idle is busy while that loop runs. Displays that never
// park (no loop, or one that always runs) update synchronously, so they are never busy.
const bool was_busy = this->display_busy_;
if (this->display_->is_idle()) {
this->display_parks_ = true;
this->display_busy_ = false;
} else {
this->display_busy_ = this->display_parks_;
}
if (was_busy && !this->display_busy_) {
// A refresh the driver gave up on (it sets its warning) leaves the panel behind the buffer: retry it
// once and forget the cached tiles, which no longer say what the panel shows
if (!this->display_->status_has_warning()) {
this->refresh_retried_ = false;
} else if (!this->refresh_retried_) {
this->refresh_retried_ = true;
this->clear_tiles_();
if (!this->refresh_pending_) {
this->first_change_ms_ = now;
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
}
}
// A stuck panel, or another owner of the display's loop, would otherwise freeze the screen silently
if (!this->display_busy_) {
this->busy_since_ms_ = now;
@@ -147,6 +207,11 @@ void VncClient::loop() {
this->service_rx_(now);
}
if (this->state_ == VncState::VNC_STATE_RUNNING) {
// A new session lays the screen out again for the new rotation and redraws all of it.
if (static_cast<uint8_t>(this->display_->get_rotation() / 90) != this->hash_rotation_) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display rotation changed"));
return;
}
// Mid-frame there is nothing to send, so a vanished server would never fail a write.
if (this->in_update_ && !blocked && !this->rx_pending_ && now - this->last_rx_ms_ >= HEARTBEAT_MS) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Server stopped sending"));
@@ -183,12 +248,20 @@ void VncClient::connect_step_(uint32_t now) {
}
void VncClient::reset_session_() {
// Pixels drawn but never shown would match the cache when the next session resends them.
if (this->refresh_pending_ || this->frame_drawn_) {
this->clear_tiles_();
}
// Hextile colours carry from tile to tile; a new server must not inherit the last one's
std::memset(this->hex_bg_, 0, sizeof(this->hex_bg_));
std::memset(this->hex_fg_, 0, sizeof(this->hex_fg_));
this->rx_pos_ = 0;
this->rx_len_ = 0;
this->rx_pending_ = false;
this->in_update_ = false;
this->request_outstanding_ = false;
this->refresh_pending_ = false;
this->need_full_ = false;
this->frame_drawn_ = false;
this->step_ = VncStep::VNC_STEP_VERSION;
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
@@ -478,6 +551,7 @@ VncParse VncClient::parse_() {
this->in_update_ = true;
this->update_start_ms_ = millis();
this->update_start_bytes_ = this->rx_total_ - (this->rx_len_ - this->rx_pos_);
this->drawn_tiles_ = 0;
this->rect_done_();
break;
case MSG_SET_COLOUR_MAP:
@@ -517,7 +591,7 @@ VncParse VncClient::parse_() {
const auto encoding = static_cast<int32_t>(read_be32(p + 8));
this->rx_pos_ += 12;
this->rects_left_--;
if (encoding == ENCODING_RAW) {
if (encoding == ENCODING_RAW || encoding == ENCODING_HEXTILE) {
if (static_cast<uint32_t>(this->rect_x_) + this->rect_w_ > this->fb_w_ ||
static_cast<uint32_t>(this->rect_y_) + this->rect_h_ > this->fb_h_) {
ESP_LOGW(TAG, "Rectangle %ux%u at %u,%u is outside the %ux%u screen", this->rect_w_, this->rect_h_,
@@ -526,10 +600,15 @@ VncParse VncClient::parse_() {
}
if (this->rect_w_ == 0 || this->rect_h_ == 0) {
this->rect_done_();
} else {
} else if (encoding == ENCODING_RAW) {
this->forget_tiles_(this->rect_x_, this->rect_y_, this->rect_w_, this->rect_h_);
this->pos_x_ = 0;
this->pos_y_ = 0;
this->step_ = VncStep::VNC_STEP_RAW;
} else {
this->pos_x_ = this->rect_x_;
this->pos_y_ = this->rect_y_;
this->step_ = VncStep::VNC_STEP_TILE;
}
} else if (encoding == ENCODING_RICH_CURSOR) {
if (this->rect_w_ > MAX_CURSOR_SIDE || this->rect_h_ > MAX_CURSOR_SIDE) {
@@ -570,6 +649,84 @@ VncParse VncClient::parse_() {
}
break;
}
case VncStep::VNC_STEP_TILE: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
if (this->budget_exhausted_()) {
return VncParse::VNC_PARSE_PAUSE;
}
const uint8_t flags = p[0];
this->tile_w_ = std::min<uint32_t>(16, static_cast<uint32_t>(this->rect_x_) + this->rect_w_ - this->pos_x_);
this->tile_h_ = std::min<uint32_t>(16, static_cast<uint32_t>(this->rect_y_) + this->rect_h_ - this->pos_y_);
this->tile_hidden_ = this->pos_x_ >= this->vis_w_ || this->pos_y_ >= this->vis_h_;
// With the raw bit set the other bits mean nothing and the pixels follow at once.
if (flags & HEXTILE_RAW) {
this->rx_pos_ += 1;
this->tile_fill_ = 0;
this->step_ = VncStep::VNC_STEP_TILE_RAW;
break;
}
const size_t need = 1 + ((flags & HEXTILE_BACKGROUND) ? this->bpp_ : 0) +
((flags & HEXTILE_FOREGROUND) ? this->bpp_ : 0) + ((flags & HEXTILE_ANY_SUBRECTS) ? 1 : 0);
if (avail < need) {
return VncParse::VNC_PARSE_MORE;
}
const uint8_t *q = p + 1;
if (flags & HEXTILE_BACKGROUND) {
std::memcpy(this->hex_bg_, q, this->bpp_);
q += this->bpp_;
}
if (flags & HEXTILE_FOREGROUND) {
std::memcpy(this->hex_fg_, q, this->bpp_);
q += this->bpp_;
}
this->subrects_left_ = (flags & HEXTILE_ANY_SUBRECTS) ? *q : 0;
this->subrects_coloured_ = (flags & HEXTILE_SUBRECTS_COLOURED) != 0;
this->rx_pos_ += need;
this->fill_tile_(0, 0, this->tile_w_, this->tile_h_, this->hex_bg_);
this->step_ = VncStep::VNC_STEP_SUBRECTS;
break;
}
case VncStep::VNC_STEP_TILE_RAW: {
const size_t total = static_cast<size_t>(this->tile_w_) * this->tile_h_ * this->bpp_;
const size_t count = std::min(avail, total - this->tile_fill_);
if (count == 0) {
return VncParse::VNC_PARSE_MORE;
}
std::memcpy(this->tile_ + this->tile_fill_, p, count);
this->rx_pos_ += count;
this->tile_fill_ += count;
if (this->tile_fill_ == total) {
this->draw_tile_();
this->next_tile_();
}
break;
}
case VncStep::VNC_STEP_SUBRECTS: {
if (this->subrects_left_ == 0) {
this->draw_tile_();
this->next_tile_();
break;
}
const size_t need = (this->subrects_coloured_ ? this->bpp_ : 0) + 2;
if (avail < need) {
return VncParse::VNC_PARSE_MORE;
}
const uint8_t *pixel = this->hex_fg_;
const uint8_t *q = p;
if (this->subrects_coloured_) {
pixel = q;
q += this->bpp_;
}
this->fill_tile_(q[0] >> 4, q[0] & 0x0F, (q[1] >> 4) + 1, (q[1] & 0x0F) + 1, pixel);
this->rx_pos_ += need;
this->subrects_left_--;
break;
}
case VncStep::VNC_STEP_SKIP: {
const auto count = static_cast<uint32_t>(std::min<size_t>(avail, this->skip_left_));
this->rx_pos_ += count;
@@ -592,6 +749,20 @@ void VncClient::rect_done_() {
}
}
void VncClient::next_tile_() {
this->step_ = VncStep::VNC_STEP_TILE;
if (static_cast<uint32_t>(this->pos_x_) + 16 < static_cast<uint32_t>(this->rect_x_) + this->rect_w_) {
this->pos_x_ += 16;
return;
}
this->pos_x_ = this->rect_x_;
if (static_cast<uint32_t>(this->pos_y_) + 16 < static_cast<uint32_t>(this->rect_y_) + this->rect_h_) {
this->pos_y_ += 16;
return;
}
this->rect_done_();
}
void VncClient::frame_done_() {
this->in_update_ = false;
this->request_outstanding_ = false;
@@ -604,9 +775,9 @@ void VncClient::frame_done_() {
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms",
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms, %u hextile tiles drawn",
this->rx_total_ - (this->rx_len_ - this->rx_pos_) - this->update_start_bytes_,
millis() - this->update_start_ms_);
millis() - this->update_start_ms_, this->drawn_tiles_);
}
}
@@ -619,8 +790,96 @@ void VncClient::draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t
this->frame_drawn_ = true;
}
void VncClient::draw_tile_() {
if (this->tile_hidden_) {
return;
}
const int w = std::min<int>(this->tile_w_, this->vis_w_ - this->pos_x_);
const int h = std::min<int>(this->tile_h_, this->vis_h_ - this->pos_y_);
uint32_t *cell = nullptr;
uint32_t hash = 0;
if (this->hash_cols_ != 0) {
if ((this->pos_x_ | this->pos_y_) & 15) {
// An off-grid tile spans several cells, and a later write to any of them would make its hash stale.
this->forget_tiles_(this->pos_x_, this->pos_y_, w, h);
} else {
cell = this->tile_hashes_ + (this->pos_y_ / 16) * this->hash_cols_ + this->pos_x_ / 16;
hash = this->tile_hash_();
if (*cell == hash) {
return;
}
}
}
this->display_->draw_pixels_at(this->pos_x_, this->pos_y_, w, h, this->tile_, this->bitness_, this->big_endian_, 0, 0,
this->tile_w_ - w);
this->frame_drawn_ = true;
this->drawn_tiles_++;
if (cell != nullptr) {
*cell = hash;
}
}
uint32_t VncClient::tile_hash_() const {
// The tile's size, pixel size and pixels; the cell gives its place. Zero means "unknown".
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS,
static_cast<uint32_t>(this->tile_w_) << 16 | this->tile_h_ << 8 | this->bpp_);
const size_t len = static_cast<size_t>(this->tile_w_) * this->tile_h_ * this->bpp_;
size_t i = 0;
for (; i + 4 <= len; i += 4) {
uint32_t word;
std::memcpy(&word, this->tile_ + i, sizeof(word)); // tile_ is aligned, so this is one load
// Byte by byte: XOR-ing whole words lets high-bit changes in two words cancel.
hash = fnv1a_hash_extend(hash, word);
}
for (; i < len; i++) {
hash = fnv1a_hash_extend(hash, this->tile_[i]);
}
return hash == 0 ? 1 : hash;
}
void VncClient::forget_tiles_(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
if (this->hash_cols_ == 0 || w == 0 || h == 0) {
return;
}
const uint16_t col_end = std::min<uint16_t>((x + w - 1) / 16, this->hash_cols_ - 1);
const uint16_t row_end = std::min<uint16_t>((y + h - 1) / 16, this->hash_rows_ - 1);
for (uint16_t row = y / 16; row <= row_end; row++) {
for (uint16_t col = x / 16; col <= col_end; col++) {
this->tile_hashes_[row * this->hash_cols_ + col] = 0;
}
}
}
void VncClient::fill_tile_(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *pixel) {
if (this->tile_hidden_ || x >= this->tile_w_ || y >= this->tile_h_) {
return;
}
w = std::min<uint8_t>(w, this->tile_w_ - x);
h = std::min<uint8_t>(h, this->tile_h_ - y);
const size_t stride = static_cast<size_t>(this->tile_w_) * this->bpp_;
const size_t row_len = static_cast<size_t>(w) * this->bpp_;
uint8_t *first = this->tile_ + y * stride + x * this->bpp_;
if (this->bpp_ == 1) {
std::memset(first, pixel[0], w);
} else {
for (size_t i = 0; i < row_len; i += 2) {
first[i] = pixel[0];
first[i + 1] = pixel[1];
}
}
for (uint8_t row = 1; row < h; row++) {
std::memcpy(first + row * stride, first, row_len);
}
}
bool VncClient::start_session_(uint32_t now) {
const bool rgb565 = this->display_->get_display_type() != display::DISPLAY_TYPE_BINARY;
VncPixelFormat format = this->pixel_format_;
if (format == VncPixelFormat::VNC_PIXEL_FORMAT_AUTO) {
format = this->display_->get_display_type() == display::DISPLAY_TYPE_BINARY
? VncPixelFormat::VNC_PIXEL_FORMAT_RGB332
: VncPixelFormat::VNC_PIXEL_FORMAT_RGB565;
}
const bool rgb565 = format == VncPixelFormat::VNC_PIXEL_FORMAT_RGB565;
this->bpp_ = rgb565 ? 2 : 1;
this->bitness_ = rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332;
@@ -632,6 +891,24 @@ bool VncClient::start_session_(uint32_t now) {
ESP_LOGW(TAG, "Server screen is %ux%u but the display is %dx%d; showing the top-left %ux%u", this->fb_w_,
this->fb_h_, disp_w, disp_h, this->vis_w_, this->vis_h_);
}
const uint16_t cols = (this->vis_w_ + 15) / 16;
const uint16_t rows = (this->vis_h_ + 15) / 16;
const auto rotation = static_cast<uint8_t>(this->display_->get_rotation() / 90);
const bool rotated = rotation != this->hash_rotation_;
this->hash_rotation_ = rotation;
if (this->tile_hashes_ == nullptr) {
this->hash_cols_ = 0;
this->hash_rows_ = 0;
} else if (static_cast<uint32_t>(cols) * rows > this->hash_capacity_) {
ESP_LOGW(TAG, "Screen has %u tiles, more than the %u cached; every tile is drawn", cols * rows,
this->hash_capacity_);
this->hash_cols_ = 0;
this->hash_rows_ = 0;
} else if (cols != this->hash_cols_ || rows != this->hash_rows_ || rotated) {
this->hash_cols_ = cols;
this->hash_rows_ = rows;
this->clear_tiles_();
}
// SetPixelFormat: true colour, in the byte order the display takes
uint8_t pixel_format[20] = {};
@@ -648,10 +925,12 @@ bool VncClient::start_session_(uint32_t now) {
return false;
}
// SetEncodings: TightVNC uses the first real encoding it supports for every rectangle.
uint8_t encodings[12] = {2, 0, 0, 2};
write_be32(encodings + 4, ENCODING_RAW);
write_be32(encodings + 8, static_cast<uint32_t>(ENCODING_RICH_CURSOR));
// SetEncodings
uint8_t encodings[4 + 4 * std::size(ENCODINGS)] = {2};
write_be16(encodings + 2, std::size(ENCODINGS));
for (size_t i = 0; i < std::size(ENCODINGS); i++) {
write_be32(encodings + 4 + 4 * i, static_cast<uint32_t>(ENCODINGS[i]));
}
if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) {
return false;
}
@@ -669,6 +948,12 @@ void VncClient::pace_(uint32_t now) {
if (this->display_busy_ || this->in_update_ || now - this->last_request_ms_ < MIN_REQUEST_SPACING_MS) {
return;
}
if (this->need_full_) {
if (this->send_update_request_(false, now)) {
this->need_full_ = false;
}
return;
}
// Repeating a parked request costs nothing on the server and detects a dead link.
if (!this->request_outstanding_ ||
(now - this->last_rx_ms_ >= HEARTBEAT_MS && now - this->last_request_ms_ >= HEARTBEAT_MS)) {
+41 -1
View File
@@ -10,6 +10,12 @@
namespace esphome::vnc {
enum class VncPixelFormat : uint8_t {
VNC_PIXEL_FORMAT_AUTO,
VNC_PIXEL_FORMAT_RGB565,
VNC_PIXEL_FORMAT_RGB332,
};
enum class VncState : uint8_t {
VNC_STATE_IDLE,
VNC_STATE_HANDSHAKE,
@@ -31,6 +37,9 @@ enum class VncStep : uint8_t {
VNC_STEP_RECT,
VNC_STEP_RECT_DONE,
VNC_STEP_RAW,
VNC_STEP_TILE,
VNC_STEP_TILE_RAW,
VNC_STEP_SUBRECTS,
VNC_STEP_SKIP,
};
@@ -55,6 +64,7 @@ class VncClient : public Component {
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
void set_pixel_format(VncPixelFormat format) { this->pixel_format_ = format; }
void setup() override;
void loop() override;
@@ -63,6 +73,8 @@ class VncClient : public Component {
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
bool is_connected() const { return this->state_ == VncState::VNC_STATE_RUNNING; }
/// Ask the server for the whole screen again.
void refresh();
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
@@ -73,6 +85,7 @@ class VncClient : public Component {
protected:
static constexpr size_t RX_BUFFER_SIZE = 512;
static constexpr size_t TILE_BUFFER_SIZE = 16 * 16 * 2;
void connect_step_(uint32_t now);
void start_handshake_(uint32_t now);
@@ -83,7 +96,13 @@ class VncClient : public Component {
bool send_client_init_();
void frame_done_();
void rect_done_();
void next_tile_();
void draw_tile_();
uint32_t tile_hash_() const;
void clear_tiles_();
void forget_tiles_(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
void draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels);
void fill_tile_(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *pixel);
void skip_(uint32_t count, VncStep next);
bool budget_exhausted_() const;
bool send_(const uint8_t *data, size_t len);
@@ -100,6 +119,9 @@ class VncClient : public Component {
LazyCallbackManager<void()> connect_callback_;
LazyCallbackManager<void()> disconnect_callback_;
HighFrequencyLoopRequester high_freq_;
// Fingerprint of the tile last drawn at each 16x16 cell, so tiles the server resends unchanged are skipped.
// A 32-bit FNV-1a collision keeps a stale tile until it changes again or vnc.refresh runs; accepted.
uint32_t *tile_hashes_{nullptr};
uint32_t update_delay_ms_{200};
uint32_t max_update_delay_ms_{1000};
@@ -131,15 +153,28 @@ class VncClient : public Component {
uint16_t rect_y_{0};
uint16_t rect_w_{0};
uint16_t rect_h_{0};
// Raw: column and row inside the rect.
// Raw: column and row inside the rect. Hextile: absolute origin of the current tile.
uint16_t pos_x_{0};
uint16_t pos_y_{0};
uint16_t tile_fill_{0};
uint16_t drawn_tiles_{0};
uint16_t hash_capacity_{0};
uint16_t hash_cols_{0};
uint16_t hash_rows_{0};
uint8_t tile_w_{0};
uint8_t tile_h_{0};
uint8_t subrects_left_{0};
uint8_t bpp_{2};
uint8_t minor_{8};
uint8_t sec_count_{0};
uint8_t text_len_{0}; // clamped to MAX_TEXT_LEN
// The display rotation the cached tiles were drawn at, in quarter turns.
uint8_t hash_rotation_{0};
uint8_t hex_bg_[2]{};
uint8_t hex_fg_[2]{};
display::ColorBitness bitness_{display::COLOR_BITNESS_565};
VncPixelFormat pixel_format_{VncPixelFormat::VNC_PIXEL_FORMAT_AUTO};
VncState state_{VncState::VNC_STATE_IDLE};
VncStep step_{VncStep::VNC_STEP_VERSION};
VncStep after_skip_{VncStep::VNC_STEP_MESSAGE};
@@ -149,14 +184,19 @@ class VncClient : public Component {
bool in_update_{false};
bool request_outstanding_{false};
bool refresh_pending_{false};
bool need_full_{false};
bool frame_drawn_{false};
bool subrects_coloured_{false};
bool tile_hidden_{false};
bool display_parks_{false};
bool display_busy_{false};
bool busy_warned_{false};
bool refresh_retried_{false}; // one retry per refresh the driver abandoned
bool network_was_up_{false};
bool big_endian_; // the pixel order the display takes
uint8_t rx_[RX_BUFFER_SIZE];
alignas(4) uint8_t tile_[TILE_BUFFER_SIZE];
};
} // namespace esphome::vnc
+13 -31
View File
@@ -5,6 +5,7 @@
#include "weikai.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
namespace esphome::weikai {
@@ -37,35 +38,34 @@ const char *p2s(uart::UARTParityOptions parity) {
}
/// @brief Display a buffer in hexadecimal format (32 hex values / line) for debug
void print_buffer(const uint8_t *data, size_t length) {
void print_buffer(const char *tag, const uint8_t *data, size_t length, int level) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
PROGMEM_STRING_TABLE(RegNamesPage0, "GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", "SIFR", "TFCNT",
"RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485");
PROGMEM_STRING_TABLE(RegNamesPage1, "GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", "TFTL", "FWTH",
"FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY");
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
const LogString *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
return LOG_STR("GPDAT");
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
return LOG_STR("GPDIR");
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
return page1 ? RegNamesPage1::get_log_str(reg & 0x0F, 0) : RegNamesPage0::get_log_str(reg & 0x0F, 0);
}
}
@@ -495,24 +495,6 @@ class Increment {
uint8_t i_;
};
/// @brief Hex converter to print/display a buffer in hexadecimal format (32 hex values / line).
/// @param buffer contains the values to display
void print_buffer(std::vector<uint8_t> buffer) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < buffer.size(); i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", buffer[i]);
if (i % 32 == 31) {
ESP_LOGI(TAG, " %s", hex_buffer);
}
}
if (buffer.size() % 32) {
// null terminate if incomplete line
hex_buffer[3 * (buffer.size() % 32) + 1] = 0;
ESP_LOGI(TAG, " %s", hex_buffer);
}
}
/// @brief test the write_array method
void WeikaiChannel::uart_send_test_(char *message) {
auto start_exec = micros();
@@ -560,7 +542,7 @@ bool WeikaiChannel::uart_receive_test_(char *message) {
for (size_t i = 0; i < RING_BUFFER_SIZE; i++) {
if (buffer[i] != i % XFER_MAX_SIZE) {
ESP_LOGE(TAG, "Read buffer contains error b=%x i=%x", buffer[i], i % XFER_MAX_SIZE);
print_buffer(buffer);
print_buffer(TAG, buffer.data(), buffer.size(), ESPHOME_LOG_LEVEL_INFO);
status = false;
break;
}
+6
View File
@@ -40,6 +40,12 @@ constexpr size_t FIFO_SIZE = 256;
/// @brief size of the ring buffer set to size of the FIFO
constexpr size_t RING_BUFFER_SIZE = FIFO_SIZE;
/// @brief Name of a register for the log messages
const LogString *reg_to_str(int reg, bool page1);
/// Logs a buffer in hexadecimal, 32 values per line, under @p tag at @p level.
void print_buffer(const char *tag, const uint8_t *data, size_t length, int level = ESPHOME_LOG_LEVEL_VERY_VERBOSE);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/// @brief This is an helper class that provides a simple ring buffers that works as a FIFO
/// @details This ring buffer is used to buffer the bytes received in the FIFO of the Weika device. The best way to read
+6 -37
View File
@@ -8,38 +8,7 @@
namespace esphome::weikai_i2c {
ESPHOME_LOG_TAG(TAG, "weikai_i2c");
/// @brief Display a buffer in hexadecimal format (32 hex values / line).
void print_buffer(const uint8_t *data, size_t length) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
using namespace weikai;
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
}
}
enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO
/// @brief Computes the I²C bus's address used to access the component
@@ -72,11 +41,11 @@ uint8_t WeikaiRegisterI2C::read_reg() const {
if (error == i2c::NO_ERROR) {
this->comp_->status_clear_warning();
ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
} else { // error
this->comp_->status_set_warning();
ESP_LOGE(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
}
return value;
}
@@ -91,7 +60,7 @@ void WeikaiRegisterI2C::read_fifo(uint8_t *data, size_t length) const {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_,
(int) error, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
} else { // error
this->comp_->status_set_warning();
@@ -108,11 +77,11 @@ void WeikaiRegisterI2C::write_reg(uint8_t value) {
if (error == i2c::NO_ERROR) {
this->comp_->status_clear_warning();
ESP_LOGVV(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
} else { // error
this->comp_->status_set_warning();
ESP_LOGE(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%d", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
}
}
@@ -126,7 +95,7 @@ void WeikaiRegisterI2C::write_fifo(uint8_t *data, size_t length) {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
ESP_LOGVV(TAG, "WK2168Reg::write_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_,
(int) error, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
} else { // error
this->comp_->status_set_warning();
+4 -37
View File
@@ -35,39 +35,6 @@ const char *p2s(uart::UARTParityOptions parity) {
}
}
/// @brief Display a buffer in hexadecimal format (32 hex values / line).
void print_buffer(const uint8_t *data, size_t length) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
}
}
enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO
enum CmdType { WRITE_CMD = 0, READ_CMD = 1 }; ///< Read or Write transfer
@@ -101,7 +68,7 @@ uint8_t WeikaiRegisterSPI::read_reg() const {
spi_comp->disable();
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, cmd), cmd,
reg_to_str(this->register_, this->comp_->page1()), this->channel_, val);
LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, val);
return val;
}
@@ -116,7 +83,7 @@ void WeikaiRegisterSPI::read_fifo(uint8_t *data, size_t length) const {
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd,
this->channel_, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
}
@@ -128,7 +95,7 @@ void WeikaiRegisterSPI::write_reg(uint8_t value) {
spi_comp->disable();
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, buf[0]),
buf[0], reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]);
buf[0], LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, buf[1]);
}
void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) {
@@ -143,7 +110,7 @@ void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) {
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd,
this->channel_, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
}
-2
View File
@@ -392,8 +392,6 @@ template<typename... X> class TemplatableValue<std::string, X...> {
}
bool has_value() const { return this->type_ != NONE; }
/// Whether the value comes from a lambda, so it may differ between reads.
bool is_lambda() const { return this->type_ == LAMBDA || this->type_ == STATELESS_LAMBDA; }
std::string value(X... x) const {
switch (this->type_) {
@@ -0,0 +1,70 @@
"""Tests for the ds3231 hub and alarm action validation."""
import pytest
from esphome import config_validation as cv
from esphome.components import ds3231
from esphome.components.ds3231 import (
ALARM_1_MODES,
ALARM_2_MODES,
CONF_DAY_OF_MONTH,
CONF_DAY_OF_WEEK,
SQUARE_WAVE_FREQUENCIES,
SquareWaveFrequency,
validate_square_wave_frequency,
)
from esphome.const import CONF_MODE
@pytest.mark.parametrize(
("value", "frequency"),
[
("1Hz", SquareWaveFrequency.SQUARE_WAVE_1HZ),
("1024Hz", SquareWaveFrequency.SQUARE_WAVE_1024HZ),
("1.024kHz", SquareWaveFrequency.SQUARE_WAVE_1024HZ),
("4096 Hz", SquareWaveFrequency.SQUARE_WAVE_4096HZ),
(8192, SquareWaveFrequency.SQUARE_WAVE_8192HZ),
],
)
def test_square_wave_frequency_accepts_every_spelling(
value: str | int, frequency: object
) -> None:
assert validate_square_wave_frequency(value) == frequency
def test_square_wave_frequency_rejects_other_rates() -> None:
with pytest.raises(cv.Invalid, match="valid options are"):
validate_square_wave_frequency("2048Hz")
assert list(SQUARE_WAVE_FREQUENCIES) == [1, 1024, 4096, 8192]
def test_alarm_modes_map_to_their_enumerators() -> None:
assert str(ALARM_1_MODES["every_second"]) == "ds3231::ALARM_MODE_EVERY_SECOND"
assert str(ALARM_1_MODES["weekly"]) == "ds3231::ALARM_MODE_WEEKLY"
assert "every_second" not in ALARM_2_MODES
def _alarm_1_schema() -> cv.Schema:
return ds3231._alarm_schema(ALARM_1_MODES, with_second=True)
def test_monthly_alarm_needs_a_day_of_month() -> None:
with pytest.raises(cv.Invalid, match="'day_of_month' is required in monthly mode"):
_alarm_1_schema()({CONF_MODE: "monthly"})
def test_weekly_alarm_needs_a_day_of_week() -> None:
with pytest.raises(cv.Invalid, match="'day_of_week' is required in weekly mode"):
_alarm_1_schema()({CONF_MODE: "weekly"})
def test_daily_alarm_fills_the_unused_days() -> None:
config = _alarm_1_schema()({CONF_MODE: "daily"})
assert config[CONF_DAY_OF_MONTH] == 1
assert config[CONF_DAY_OF_WEEK] == 1
def test_alarm_2_rejects_every_second() -> None:
schema = ds3231._alarm_schema(ALARM_2_MODES, with_second=False)
with pytest.raises(cv.Invalid, match="Unknown value 'every_second'"):
schema({CONF_MODE: "every_second"})
@@ -0,0 +1,76 @@
"""Validate RX8111 alarm and timer options before generating firmware."""
import pytest
from esphome.components.rx8111.time import (
_ALARM_DURATION,
ALARM_FIELD_UNSET,
CONF_ALARM_DAY,
CONF_ALARM_ENABLED,
CONF_ALARM_WEEKDAY,
CONF_DAY,
CONF_ON_ALARM,
CONF_ON_TIMER,
CONF_SECOND,
CONF_TIMER_COUNT,
CONF_TIMER_ENABLED,
CONF_WEEKDAY,
_validate_alarm_config,
_validate_alarm_options,
_validate_set_alarm,
)
import esphome.config_validation as cv
@pytest.mark.parametrize(
"config",
[{}, {CONF_SECOND: 0, CONF_WEEKDAY: 1, CONF_DAY: 1}],
)
def test_set_alarm_rejects_empty_or_conflicting_fields(config: dict) -> None:
with pytest.raises(cv.Invalid):
_validate_set_alarm(config)
def test_set_alarm_marks_missing_fields_unset() -> None:
config = _validate_set_alarm({CONF_SECOND: 5})
assert config[CONF_SECOND] == 5
assert config[CONF_DAY] == ALARM_FIELD_UNSET
def test_fixed_alarm_rejects_day_and_weekday() -> None:
with pytest.raises(cv.Invalid):
_validate_alarm_options({CONF_ALARM_DAY: 1, CONF_ALARM_WEEKDAY: 2})
@pytest.mark.parametrize("duration", ["0s", "28d", "29d"])
def test_relative_alarm_rejects_out_of_range_duration(duration: str) -> None:
with pytest.raises(cv.Invalid):
_ALARM_DURATION(duration)
def test_relative_alarm_accepts_mixed_units() -> None:
assert _ALARM_DURATION({"days": 1, "minutes": 1}).total_seconds == 86460
@pytest.mark.parametrize(
"config",
[
{CONF_TIMER_ENABLED: True},
{CONF_ON_TIMER: [{}]},
{CONF_TIMER_ENABLED: False, CONF_ON_TIMER: [{}]},
],
)
def test_timer_requires_count(config: dict) -> None:
with pytest.raises(cv.Invalid, match="timer_count"):
_validate_alarm_config(config)
def test_timer_count_allows_explicit_disabled_configuration() -> None:
config = {CONF_TIMER_COUNT: 0xFFFFFF, CONF_TIMER_ENABLED: False}
assert _validate_alarm_config(config) == config
def test_on_alarm_requires_enabled_alarm() -> None:
with pytest.raises(cv.Invalid, match="on_alarm"):
_validate_alarm_config({CONF_ON_ALARM: [{}]})
assert _validate_alarm_config({CONF_ON_ALARM: [{}], CONF_ALARM_ENABLED: True})
+52 -2
View File
@@ -1,9 +1,15 @@
"""Tests for teleinfo sensor tag presets."""
import logging
import pytest
from esphome.components.teleinfo import CONF_TAG_NAME
from esphome.components.teleinfo.sensor import apply_tag_config
from esphome.components.teleinfo.sensor import (
TIC_TAG_CONFIGS,
TIC_TEXT_TAGS,
apply_tag_config,
)
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
@@ -11,6 +17,7 @@ from esphome.const import (
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
@@ -22,6 +29,7 @@ from esphome.const import (
UNIT_KILOVOLT_AMPS,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MINUTE,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
@@ -60,7 +68,13 @@ from esphome.const import (
("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT),
("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
(
"PMAX",
UNIT_VOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
("PEJP", UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT),
],
)
def test_known_tag_preset(
@@ -73,6 +87,42 @@ def test_known_tag_preset(
assert CONF_ICON not in config
def test_no_prefix_is_prefix_of_another() -> None:
prefixes = [*TIC_TAG_CONFIGS, *TIC_TEXT_TAGS]
assert len(prefixes) == len(set(prefixes))
for prefix in prefixes:
for other in prefixes:
assert prefix == other or not other.startswith(prefix)
@pytest.mark.parametrize("tag", ["NTARF", "NJOURF", "NJOURF+1", "RELAIS", "VTIC"])
def test_unitless_tag_gets_no_unit(tag: str, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
config = apply_tag_config({CONF_TAG_NAME: tag})
assert config == {CONF_TAG_NAME: tag}
assert not caplog.records
@pytest.mark.parametrize("tag", ["LTARF", "NGTF", "PTEC", "OPTARIF", "MSG1", "PPOT"])
def test_text_tag_gets_no_unit_and_warns(
tag: str, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
config = apply_tag_config({CONF_TAG_NAME: tag})
assert config == {CONF_TAG_NAME: tag}
assert "text_sensor" in caplog.text
def test_unitless_tag_keeps_user_settings() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "NTARF", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT}
)
assert config == {
CONF_TAG_NAME: "NTARF",
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
}
def test_unknown_tag_keeps_legacy_defaults() -> None:
config = apply_tag_config({CONF_TAG_NAME: "MYTAG"})
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
+28
View File
@@ -168,6 +168,7 @@ def test_it8951_owned_by_vnc(
assert "set_reconnect_interval(5000);" in main_cpp
assert "set_update_delay(200);" in main_cpp
assert "set_max_update_delay(1000);" in main_cpp
assert "set_pixel_format(vnc::VncPixelFormat::VNC_PIXEL_FORMAT_AUTO);" in main_cpp
def test_epaper_spi_owned_by_vnc(
@@ -221,6 +222,7 @@ vnc:
reconnect_interval: 2s
update_delay: 0ms
max_update_delay: 3s
pixel_format: RGB332
on_connect:
- logger.log: up
on_disconnect:
@@ -234,6 +236,7 @@ vnc:
assert "set_reconnect_interval(2000);" in main_cpp
assert "set_update_delay(0);" in main_cpp
assert "set_max_update_delay(3000);" in main_cpp
assert "set_pixel_format(vnc::VncPixelFormat::VNC_PIXEL_FORMAT_RGB332);" in main_cpp
assert "add_on_connect_callback(" in main_cpp
assert "add_on_disconnect_callback(" in main_cpp
@@ -260,6 +263,28 @@ vnc:
assert TEST_CARD not in main_cpp
def test_action_codegen(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
"""
vnc:
- id: remote
host: 192.168.1.20
interval:
- interval: 60s
then:
- vnc.refresh
""",
)
)
assert "remote->refresh();" in main_cpp
@pytest.mark.parametrize(
("display_options", "error"),
[
@@ -389,6 +414,9 @@ display:
id="reconnect",
),
pytest.param(" port: 0\n", "value must be at least 1", id="port"),
pytest.param(
" pixel_format: rgb888\n", "Unknown value 'rgb888'", id="format"
),
],
)
def test_option_rejected(
+64 -5
View File
@@ -1,15 +1,74 @@
ds3231:
- id: ds3231_component
i2c_id: i2c_bus
address: 0x68
sensor:
- platform: ds3231
ds3231_id: ds3231_component
name: DS3231 Temperature
update_interval: never
binary_sensor:
- platform: ds3231
ds3231_id: ds3231_component
alarm_1:
name: DS3231 Alarm 1
alarm_2:
name: DS3231 Alarm 2
oscillator_stopped:
name: DS3231 Oscillator Stopped
time:
- platform: ds3231
id: ds3231_time
ds3231_id: ds3231_component
update_interval: never
interval:
- interval: 60s
then:
- ds3231.force_temperature_conversion: ds3231_component
button:
- platform: template
name: DS3231 Actions
on_press:
- ds3231.write_time:
id: ds3231_time
- ds3231.read_time: ds3231_time
- ds3231.set_alarm_1:
id: ds3231_component
mode: daily
hour: 7
minute: 30
second: 15
- ds3231.set_alarm_1:
mode: every_second
enabled: false
- ds3231.set_alarm_1:
mode: monthly
day_of_month: 15
hour: !lambda return 12;
- ds3231.set_alarm_2:
mode: weekly
day_of_week: 2
hour: 6
minute: !lambda return 45;
- ds3231.set_alarm_2:
mode: every_minute
- ds3231.enable_alarm:
alarm: 1
- ds3231.disable_alarm:
alarm: 2
- ds3231.clear_alarm:
alarm: !lambda return 1;
- if:
condition:
ds3231.is_alarm_triggered:
alarm: 2
then:
- ds3231.clear_alarm:
alarm: 2
- ds3231.enable_square_wave:
frequency: 1Hz
- ds3231.disable_square_wave: ds3231_component
- ds3231.set_32khz_output:
state: true
- ds3231.set_aging_offset:
offset: !lambda return 3;
@@ -0,0 +1,15 @@
ds3231:
- id: ds3231_component
i2c_id: i2c_bus
address: 0x68
interrupt_pin:
number: ${interrupt_pin}
mode:
input: true
pullup: true
output_32khz: true
auto_clear_alarms: false
on_alarm_1:
- logger.log: "DS3231 alarm 1"
- ds3231.clear_alarm:
alarm: 1
+14
View File
@@ -0,0 +1,14 @@
ds3231:
- id: ds3231_component
i2c_id: i2c_bus
address: 0x68
square_wave_frequency: 1024Hz
battery_backed_square_wave: false
output_32khz: false
aging_offset: -2
auto_clear_alarms: true
alarm_check_interval: 2s
on_alarm_1:
- logger.log: "DS3231 alarm 1"
on_alarm_2:
- logger.log: "DS3231 alarm 2"
@@ -0,0 +1,7 @@
substitutions:
interrupt_pin: GPIO14
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
ds3231_hub: !include hub-interrupt.yaml
ds3231: !include common.yaml
@@ -1,3 +1,7 @@
substitutions:
interrupt_pin: GPIO13
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
ds3231_hub: !include hub-interrupt.yaml
ds3231: !include common.yaml
@@ -1,3 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
ds3231_hub: !include hub-polling.yaml
ds3231: !include common.yaml
+36
View File
@@ -3,8 +3,44 @@ esphome:
then:
- rx8111.read_time: rtc
- rx8111.write_time: rtc
- rx8111.set_alarm:
id: rtc
second: 0
minute: 30
hour: 7
weekday: 2
- rx8111.set_alarm:
id: rtc
day: 15
- rx8111.schedule_alarm_in:
id: rtc
duration: 2min
- rx8111.set_alarm:
id: rtc
minute: !lambda return uint16_t{15};
- rx8111.schedule_alarm_in:
id: rtc
duration: !lambda return uint32_t{60};
- rx8111.clear_alarm_flag: rtc
- rx8111.clear_timer_flag: rtc
- rx8111.stop_timer: rtc
- rx8111.start_timer: rtc
- rx8111.unset_alarm: rtc
time:
- platform: rx8111
id: rtc
i2c_id: i2c_bus
alarm_enabled: true
alarm_second: 0
alarm_minute: 30
alarm_hour: 7
timer_count: 2
timer_clock: 1hz
event_poll_interval: 500ms
on_alarm:
then:
- rx8111.clear_alarm_flag: rtc
on_timer:
then:
- rx8111.clear_timer_flag: rtc
File diff suppressed because it is too large Load Diff
+12
View File
@@ -100,6 +100,14 @@ sensor:
name: pcoup
tag_name: PCOUP
teleinfo_id: test_teleinfo
- platform: teleinfo
name: ntarf
tag_name: NTARF
teleinfo_id: test_teleinfo
- platform: teleinfo
name: pejp
tag_name: PEJP
teleinfo_id: test_teleinfo
# Explicit override - user-specified unit should not be overwritten
- platform: teleinfo
name: hchp_override
@@ -117,3 +125,7 @@ text_sensor:
name: optarif
tag_name: OPTARIF
teleinfo_id: test_teleinfo
- platform: teleinfo
name: ltarf
tag_name: LTARF
teleinfo_id: test_teleinfo
@@ -0,0 +1,75 @@
#include "esphome/components/unicode/unicode.h"
#include <gtest/gtest.h>
#include <cstring>
#include <memory>
namespace esphome::unicode::testing {
struct Decoded {
uint32_t code_point;
size_t length;
};
// Decodes the first sequence of text from an allocation of exactly its size, so a read past the
// terminator is a heap overflow the sanitizer reports instead of a silent pass
static Decoded decode(const char *text) {
const size_t size = std::strlen(text) + 1;
auto buffer = std::make_unique<char[]>(size);
std::memcpy(buffer.get(), text, size);
Decoded result{};
result.code_point = extract_unicode_codepoint(buffer.get(), &result.length);
return result;
}
TEST(Unicode, DecodesEachSequenceLength) {
EXPECT_EQ(decode("A").code_point, 0x41u);
EXPECT_EQ(decode("A").length, 1u);
EXPECT_EQ(decode("\xC3\xA9").code_point, 0xE9u); // é
EXPECT_EQ(decode("\xC3\xA9").length, 2u);
EXPECT_EQ(decode("\xE2\x82\xAC").code_point, 0x20ACu); // €
EXPECT_EQ(decode("\xE2\x82\xAC").length, 3u);
EXPECT_EQ(decode("\xF0\x9F\x98\x80").code_point, 0x1F600u); // 😀
EXPECT_EQ(decode("\xF0\x9F\x98\x80").length, 4u);
}
TEST(Unicode, AcceptsTheEdgeOfEachRange) {
// The smallest value of each length, both neighbours of the surrogate range and the last code point
EXPECT_EQ(decode("\xC2\x80").code_point, 0x80u);
EXPECT_EQ(decode("\xC2\x80").length, 2u);
EXPECT_EQ(decode("\xE0\xA0\x80").code_point, 0x800u);
EXPECT_EQ(decode("\xE0\xA0\x80").length, 3u);
EXPECT_EQ(decode("\xED\x9F\xBF").code_point, 0xD7FFu);
EXPECT_EQ(decode("\xEE\x80\x80").code_point, 0xE000u);
EXPECT_EQ(decode("\xF0\x90\x80\x80").code_point, 0x10000u);
EXPECT_EQ(decode("\xF0\x90\x80\x80").length, 4u);
EXPECT_EQ(decode("\xF4\x8F\xBF\xBF").code_point, 0x10FFFFu);
EXPECT_EQ(decode("\xF4\x8F\xBF\xBF").length, 4u);
}
TEST(Unicode, EmptyStringGivesNothing) {
EXPECT_EQ(decode("").code_point, 0u);
EXPECT_EQ(decode("").length, 0u);
}
TEST(Unicode, TruncatedSequenceStopsAtTheTerminator) {
for (const char *text : {"\xC3", "\xE2\x82", "\xE2", "\xF0\x9F\x98", "\xF0\x9F", "\xF0"}) {
EXPECT_EQ(decode(text).code_point, 0u) << text;
EXPECT_EQ(decode(text).length, 0u) << text;
}
}
TEST(Unicode, RejectsMalformedSequences) {
EXPECT_EQ(decode("\x80").length, 0u); // lone continuation byte
EXPECT_EQ(decode("\xF8\x88\x80\x80\x80").length, 0u); // 5-byte lead
EXPECT_EQ(decode("\xC0\xAF").length, 0u); // overlong '/'
EXPECT_EQ(decode("\xE0\x80\xAF").length, 0u); // overlong '/'
EXPECT_EQ(decode("\xF0\x80\x80\xAF").length, 0u); // overlong '/'
EXPECT_EQ(decode("\xED\xA0\x80").length, 0u); // surrogate U+D800
EXPECT_EQ(decode("\xF4\x90\x80\x80").length, 0u); // U+110000, past the last code point
EXPECT_EQ(decode("\xF5\x80\x80\x80").length, 0u); // lead byte past U+10FFFF
EXPECT_EQ(decode("\xC3\x41").length, 0u); // second byte is not a continuation byte
}
} // namespace esphome::unicode::testing
+4 -1
View File
@@ -6,6 +6,7 @@ vnc:
reconnect_interval: 10s
update_delay: 300ms
max_update_delay: 2s
pixel_format: rgb565
on_connect:
- logger.log: VNC connected
on_disconnect:
@@ -14,6 +15,7 @@ vnc:
interval:
- interval: 60s
then:
- vnc.refresh: vnc_client
- if:
condition:
vnc.is_connected: vnc_client
@@ -24,4 +26,5 @@ interval:
vnc.is_connected:
id: vnc_client
then:
- logger.log: VNC is still connected
- vnc.refresh:
id: vnc_client
+1 -1
View File
@@ -132,7 +132,7 @@ TEST(VncBackoff, NextSessionStartsClean) {
set_now(now_ms() + 100);
// Leave a frame half parsed and a refresh pending, then lose the link.
s.deliver(Rfb().fbu(1).raw_rect(0, 0, 2, 1, {1, 2}));
s.deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 0).u8(0x02));
s.deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 5).u8(0x02));
ASSERT_TRUE(s.client.in_update());
s.close_peer();
s.loop();
+362 -35
View File
@@ -26,21 +26,7 @@ static void expect_block(const FakeDisplay &d, int x, int y, int w, int h, const
}
}
static void expect_solid(const FakeDisplay &d, int x, int y, int w, int h, uint16_t px, uint8_t bpp) {
expect_block(d, x, y, w, h, std::vector<uint16_t>(static_cast<size_t>(w) * h, px), bpp);
}
class VncDecode : public ::testing::Test {
protected:
void start(FakeDisplay &display, uint16_t fb_w, uint16_t fb_h) {
this->session_ = std::make_unique<Session>(&display);
this->session_->start(fb_w, fb_h);
// Past the 50 ms request spacing, so the next request goes out as soon as a frame ends.
set_now(now_ms() + 100);
}
Session &s() { return *this->session_; }
std::unique_ptr<Session> session_;
};
class VncDecode : public VncSessionTest {};
TEST_F(VncDecode, Raw16bpp) {
FakeDisplay d(64, 48);
@@ -85,6 +71,225 @@ TEST_F(VncDecode, EmptyUpdateRequestsAgainWithoutRefresh) {
EXPECT_EQ(d.updates(), 0);
}
TEST_F(VncDecode, HextileRawTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto px = pattern(256, 0x0F0F);
Rfb f;
f.fbu(1).rect(16, 16, 16, 16, HEXTILE).u8(0x01);
for (auto p : px) {
f.px(p);
}
s().deliver(f);
expect_block(d, 16, 16, 16, 16, px, 2);
EXPECT_EQ(d.drawn_count(), 256u);
}
TEST_F(VncDecode, HextileRawTileIgnoresOtherFlags) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto px = pattern(4 * 3, 0x7777);
Rfb f;
// 0x1F: Raw plus every other bit. Only the pixels follow.
f.fbu(2).rect(0, 0, 4, 3, HEXTILE).u8(0x1F);
for (auto p : px) {
f.px(p);
}
f.raw_rect(10, 10, 1, 1, {0xABCD});
s().deliver(f);
expect_block(d, 0, 0, 4, 3, px, 2);
EXPECT_EQ(d.pixel(10, 10), expect_px(0xABCD, 2));
EXPECT_TRUE(s().client.is_connected());
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
}
TEST_F(VncDecode, HextileBackgroundOnly) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().fbu(1).rect(0, 0, 16, 16, HEXTILE).u8(0x02).px(0x1234));
EXPECT_TRUE(solid(d, 0, 0, 16, 16, 0x1234));
EXPECT_EQ(d.drawn_count(), 256u);
}
TEST(VncDecodeBigEndian, HextileTilesKeepTheServerByteOrder) {
FakeDisplay d(64, 48);
Session s(&d, 1000, true);
s.start(64, 48);
set_now(now_ms() + 100);
s.deliver(
Rfb().fbu(2).rect(0, 0, 16, 16, HEXTILE).u8(0x02).u16(0x1234).rect(16, 0, 1, 1, HEXTILE).u8(0x01).u16(0xF800));
EXPECT_TRUE(solid(d, 0, 0, 16, 16, 0x1234));
EXPECT_EQ(d.pixel(16, 0), expect_px(0xF800, 2));
}
TEST_F(VncDecode, HextileForegroundSubrects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
// bg 0x1111, fg 0x2222, two subrects: (1,2) 3x4 and (10,0) 6x1.
s().deliver(Rfb()
.fbu(1)
.rect(0, 0, 16, 16, HEXTILE)
.u8(0x02 | 0x04 | 0x08)
.px(0x1111)
.px(0x2222)
.u8(2)
.u8(0x12)
.u8(0x23)
.u8(0xA0)
.u8(0x50));
for (int y = 0; y < 16; y++) {
for (int x = 0; x < 16; x++) {
const bool fg = (x >= 1 && x < 4 && y >= 2 && y < 6) || (x >= 10 && y == 0);
ASSERT_EQ(d.pixel(x, y), expect_px(fg ? 0x2222 : 0x1111, 2)) << x << "," << y;
}
}
}
TEST_F(VncDecode, HextileColouredSubrects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb()
.fbu(1)
.rect(16, 0, 16, 16, HEXTILE)
.u8(0x02 | 0x08 | 0x10)
.px(0x0001)
.u8(2)
.px(0xF800)
.u8(0x00)
.u8(0x11)
.px(0x07E0)
.u8(0xFF)
.u8(0x00));
EXPECT_TRUE(solid(d, 16, 0, 2, 2, 0xF800));
EXPECT_EQ(d.pixel(31, 15), expect_px(0x07E0, 2));
EXPECT_EQ(d.pixel(18, 0), expect_px(0x0001, 2));
EXPECT_EQ(d.pixel(30, 15), expect_px(0x0001, 2));
}
TEST_F(VncDecode, HextileCarriesColoursBetweenTiles) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb()
.fbu(1)
.rect(0, 0, 48, 16, HEXTILE)
// Tile 1: bg A, fg B, one subrect.
.u8(0x02 | 0x04 | 0x08)
.px(0xAAAA)
.px(0xBBBB)
.u8(1)
.u8(0x00)
.u8(0x00)
// Tile 2: nothing specified, so solid A.
.u8(0x00)
// Tile 3: subrects in the carried fg B on the carried bg A.
.u8(0x08)
.u8(1)
.u8(0xFF)
.u8(0x00));
EXPECT_EQ(d.pixel(0, 0), expect_px(0xBBBB, 2));
EXPECT_TRUE(solid(d, 1, 1, 15, 15, 0xAAAA));
EXPECT_TRUE(solid(d, 16, 0, 16, 16, 0xAAAA));
EXPECT_EQ(d.pixel(47, 15), expect_px(0xBBBB, 2));
EXPECT_TRUE(solid(d, 32, 0, 15, 15, 0xAAAA));
}
TEST_F(VncDecode, HextileCarriesColoursAcrossRawTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto raw = pattern(256, 0x0400);
Rfb f;
f.fbu(1).rect(0, 16, 48, 16, HEXTILE).u8(0x02 | 0x04).px(0xCCCC).px(0xDDDD).u8(0x01);
for (auto p : raw) {
f.px(p);
}
f.u8(0x08).u8(1).u8(0x00).u8(0x00);
s().deliver(f);
EXPECT_TRUE(solid(d, 0, 16, 16, 16, 0xCCCC));
expect_block(d, 16, 16, 16, 16, raw, 2);
EXPECT_EQ(d.pixel(32, 16), expect_px(0xDDDD, 2));
EXPECT_EQ(d.pixel(33, 16), expect_px(0xCCCC, 2));
EXPECT_EQ(d.pixel(47, 31), expect_px(0xCCCC, 2));
}
TEST_F(VncDecode, HextileSingleEdgeTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto px = pattern(13 * 7, 0x3000);
Rfb f;
f.fbu(1).rect(5, 3, 13, 7, HEXTILE).u8(0x01);
for (auto p : px) {
f.px(p);
}
s().deliver(f);
expect_block(d, 5, 3, 13, 7, px, 2);
EXPECT_EQ(d.drawn_count(), 13u * 7u);
}
TEST_F(VncDecode, HextileTileGrid) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
// 37x21 is 3x2 tiles: 16, 16, 5 wide and 16, 5 tall.
Rfb f;
f.fbu(1).rect(2, 1, 37, 21, HEXTILE);
for (int i = 0; i < 6; i++) {
f.u8(0x02).px(0x1000 + i);
}
s().deliver(f);
const int xs[] = {2, 18, 34};
const int ws[] = {16, 16, 5};
const int ys[] = {1, 17};
const int hs[] = {16, 5};
for (int row = 0; row < 2; row++) {
for (int col = 0; col < 3; col++) {
EXPECT_TRUE(solid(d, xs[col], ys[row], ws[col], hs[row], 0x1000 + row * 3 + col));
}
}
EXPECT_EQ(d.drawn_count(), 37u * 21u);
}
TEST_F(VncDecode, HextileSubrectClippedToTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
// A 5x5 edge tile with a subrect at 3,3 claiming 16x16, and one starting outside the tile.
s().deliver(Rfb()
.fbu(1)
.rect(59, 43, 5, 5, HEXTILE)
.u8(0x02 | 0x04 | 0x08)
.px(0x0101)
.px(0xFFFF)
.u8(2)
.u8(0x33)
.u8(0xFF)
.u8(0x90)
.u8(0x00));
for (int y = 0; y < 5; y++) {
for (int x = 0; x < 5; x++) {
const bool fg = x >= 3 && y >= 3;
ASSERT_EQ(d.pixel(59 + x, 43 + y), expect_px(fg ? 0xFFFF : 0x0101, 2)) << x << "," << y;
}
}
EXPECT_EQ(d.drawn_count(), 25u);
EXPECT_TRUE(s().client.is_connected());
}
TEST_F(VncDecode, Hextile8bpp) {
FakeDisplay d(64, 48, display::DISPLAY_TYPE_BINARY);
this->start(d, 64, 48);
auto raw = pattern(4, 0x00E3);
Rfb f;
f.fbu(1).rect(0, 0, 18, 2, HEXTILE).u8(0x02 | 0x04 | 0x08).px(0x00, 1).px(0xFF, 1).u8(1).u8(0x10).u8(0x00).u8(0x01);
for (auto p : raw) {
f.px(p & 0xFF, 1);
}
s().deliver(f);
EXPECT_EQ(d.pixel(0, 0), expect_px(0x00, 1));
EXPECT_EQ(d.pixel(1, 0), expect_px(0xFF, 1));
EXPECT_EQ(d.pixel(1, 1), expect_px(0x00, 1));
for (int i = 0; i < 4; i++) {
EXPECT_EQ(d.pixel(16 + i % 2, i / 2), expect_px(raw[i] & 0xFF, 1));
}
}
TEST_F(VncDecode, RichCursorIsSkippedAndNeverRefreshes) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
@@ -161,9 +366,15 @@ TEST(VncDecodeClip, ServerLargerThanDisplay) {
auto raw = pattern(90 * 70, 0x0021);
Rfb f;
f.fbu(3).raw_rect(5, 5, 90, 70, raw);
// A raw rect straddling both edges, and one entirely off screen.
f.raw_rect(56, 40, 20, 20, std::vector<uint16_t>(20 * 20, 0x5000));
f.raw_rect(70, 50, 16, 16, pattern(256, 1));
// A hextile rect straddling both edges, and one entirely off screen.
f.rect(56, 40, 20, 20, HEXTILE);
for (int i = 0; i < 4; i++) {
f.u8(0x02).px(0x5000 + i);
}
f.rect(70, 50, 16, 16, HEXTILE).u8(0x01);
for (auto p : pattern(256, 1)) {
f.px(p);
}
s.deliver(f);
EXPECT_EQ(d.out_of_bounds(), 0);
for (int y = 5; y < 40; y++) {
@@ -171,7 +382,7 @@ TEST(VncDecodeClip, ServerLargerThanDisplay) {
ASSERT_EQ(d.pixel(x, y), expect_px(raw[(y - 5) * 90 + (x - 5)], 2)) << x << "," << y;
}
}
expect_solid(d, 56, 40, 8, 8, 0x5000, 2);
EXPECT_TRUE(solid(d, 56, 40, 8, 8, 0x5000));
EXPECT_EQ(d.pixel(4, 4), UNSET);
EXPECT_TRUE(s.client.is_connected());
EXPECT_EQ(hex(s.take()), hex(fbur(true, 64, 48)));
@@ -228,8 +439,8 @@ TEST_P(VncRectOutside, Reconnects) {
INSTANTIATE_TEST_SUITE_P(Rects, VncRectOutside,
::testing::Values(OutsideCase{60, 0, 5, 1, RAW}, OutsideCase{0, 47, 1, 2, RAW},
OutsideCase{65535, 0, 2, 1, RAW}, OutsideCase{0, 65535, 1, 2, RAW},
OutsideCase{64, 0, 1, 1, RAW}, OutsideCase{0, 0, 65, 1, RAW}));
OutsideCase{65535, 0, 2, 1, RAW}, OutsideCase{0, 65535, 1, 2, HEXTILE},
OutsideCase{64, 0, 1, 1, HEXTILE}, OutsideCase{0, 0, 65, 1, HEXTILE}));
// --- A frame whose last step needs no bytes must still finish when its last byte arrives.
@@ -251,6 +462,43 @@ class VncZeroByteEnd : public VncDecode {
}
};
TEST_F(VncZeroByteEnd, SolidHextileTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(1).rect(0, 0, 32, 16, HEXTILE).u8(0x01);
for (auto p : pattern(256, 3)) {
f.px(p);
}
f.u8(0x02).px(0x4444);
this->deliver_bytewise(f.bytes, f.bytes.size());
EXPECT_TRUE(solid(d, 16, 0, 16, 16, 0x4444));
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncZeroByteEnd, CarriedBackgroundTile) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(1).rect(0, 0, 32, 16, HEXTILE).u8(0x02).px(0x5555).u8(0x00);
this->deliver_bytewise(f.bytes, f.bytes.size());
EXPECT_TRUE(solid(d, 0, 0, 32, 16, 0x5555));
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncZeroByteEnd, TileWithZeroSubrects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(1).rect(0, 0, 16, 16, HEXTILE).u8(0x02 | 0x08).px(0x6666).u8(0);
this->deliver_bytewise(f.bytes, f.bytes.size());
EXPECT_TRUE(solid(d, 0, 0, 16, 16, 0x6666));
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncZeroByteEnd, ZeroSizeCursor) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
@@ -285,17 +533,14 @@ TEST_F(VncZeroByteEnd, EmptyCutTextAfterFrame) {
EXPECT_EQ(d.pixel(1, 0), expect_px(0x7002, 2));
}
// --- The 20 ms budget: with a slow display, one row per pass.
// --- The 20 ms budget: with a slow display, one tile per pass.
TEST_F(VncDecode, BudgetPausesBetweenRectsAndFinishesOnTheLastOne) {
TEST_F(VncDecode, BudgetPausesBetweenTilesAndFinishesOnTheLastOne) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
d.set_draw_delay_us(25000);
Rfb f;
f.fbu(3)
.raw_rect(0, 0, 16, 1, std::vector<uint16_t>(16, 0x0A0A))
.raw_rect(16, 0, 16, 1, std::vector<uint16_t>(16, 0x0B0B))
.raw_rect(32, 0, 16, 1, std::vector<uint16_t>(16, 0x0C0C));
f.fbu(1).rect(0, 0, 48, 16, HEXTILE).u8(0x02).px(0x0A0A).u8(0x02).px(0x0B0B).u8(0x02).px(0x0C0C);
s().send(f);
s().loop();
EXPECT_EQ(d.draw_calls(), 1);
@@ -312,7 +557,7 @@ TEST_F(VncDecode, BudgetPausesBetweenRectsAndFinishesOnTheLastOne) {
s().loop(2);
EXPECT_FALSE(s().client.rx_pending());
EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency());
expect_solid(d, 32, 0, 16, 1, 0x0C0C, 2);
EXPECT_TRUE(solid(d, 32, 0, 16, 16, 0x0C0C));
}
TEST_F(VncDecode, BudgetPausesInsideARawRect) {
@@ -393,6 +638,8 @@ static Generated generate(uint32_t seed) {
g.handshake_tx = cat({g.handshake_tx, bpp == 2 ? set_pixel_format_565() : set_pixel_format_332(), set_encodings(),
fbur(false, g.vis_w, g.vis_h)});
uint16_t bg = 0;
uint16_t fg = 0;
const int frames = rnd(3, 8);
for (int frame = 0; frame < frames; frame++) {
Rfb f;
@@ -420,19 +667,99 @@ static Generated generate(uint32_t seed) {
if (kind == 9) {
const int x = rnd(0, fb_w);
const int y = rnd(0, fb_h);
f.rect(x, y, rnd(0, 1) ? 0 : rnd(0, fb_w - x), 0, RAW);
f.rect(x, y, rnd(0, 1) ? 0 : rnd(0, fb_w - x), 0, rnd(0, 1) ? RAW : HEXTILE);
continue;
}
const int x = rnd(0, fb_w - 1);
const int y = rnd(0, fb_h - 1);
const int w = rnd(1, std::min(fb_w - x, 48));
const int h = rnd(1, std::min(fb_h - y, 40));
f.rect(x, y, w, h, RAW);
for (int yy = 0; yy < h; yy++) {
for (int xx = 0; xx < w; xx++) {
uint16_t p = pixel();
f.px(p, bpp);
g.model.set(x + xx, y + yy, p);
if (kind < 4) {
f.rect(x, y, w, h, RAW);
for (int yy = 0; yy < h; yy++) {
for (int xx = 0; xx < w; xx++) {
uint16_t p = pixel();
f.px(p, bpp);
g.model.set(x + xx, y + yy, p);
}
}
continue;
}
f.rect(x, y, w, h, HEXTILE);
bool bg_valid = false;
bool fg_valid = false;
for (int ty = y; ty < y + h; ty += 16) {
for (int tx = x; tx < x + w; tx += 16) {
const int tw = std::min(16, x + w - tx);
const int th = std::min(16, y + h - ty);
const int choice = rnd(0, 5);
if (choice == 0) {
f.u8(static_cast<uint8_t>(0x01 | (rnd(0, 15) << 1)));
for (int yy = 0; yy < th; yy++) {
for (int xx = 0; xx < tw; xx++) {
uint16_t p = pixel();
f.px(p, bpp);
g.model.set(tx + xx, ty + yy, p);
}
}
continue;
}
uint8_t flags = 0;
const bool set_bg = !bg_valid || choice == 1 || rnd(0, 3) == 0;
const bool subrects = choice >= 3;
const bool coloured = choice == 5;
const bool set_fg = subrects && !coloured && (!fg_valid || choice == 3);
const bool extra_fg = coloured && rnd(0, 1);
if (set_bg) {
flags |= 0x02;
bg = pixel();
bg_valid = true;
}
if (set_fg || extra_fg) {
flags |= 0x04;
fg = pixel();
fg_valid = true;
}
if (subrects) {
flags |= 0x08;
}
if (coloured) {
flags |= 0x10;
}
f.u8(flags);
if (flags & 0x02) {
f.px(bg, bpp);
}
if (flags & 0x04) {
f.px(fg, bpp);
}
std::vector<uint16_t> tile(static_cast<size_t>(tw) * th, bg);
if (subrects) {
const int count = rnd(0, 6);
f.u8(count);
for (int i = 0; i < count; i++) {
uint16_t colour = fg;
if (coloured) {
colour = pixel();
f.px(colour, bpp);
}
const int sx = rnd(0, 15);
const int sy = rnd(0, 15);
const int sw = rnd(1, 16);
const int sh = rnd(1, 16);
f.u8(static_cast<uint8_t>((sx << 4) | sy)).u8(static_cast<uint8_t>(((sw - 1) << 4) | (sh - 1)));
for (int yy = sy; yy < std::min(sy + sh, th); yy++) {
for (int xx = sx; xx < std::min(sx + sw, tw); xx++) {
tile[yy * tw + xx] = colour;
}
}
}
}
for (int yy = 0; yy < th; yy++) {
for (int xx = 0; xx < tw; xx++) {
g.model.set(tx + xx, ty + yy, tile[yy * tw + xx]);
}
}
}
}
}
+11 -4
View File
@@ -41,6 +41,7 @@ TEST(VncHandshake, Rfb38NonePipelined) {
struct PixelFormatCase {
display::DisplayType type;
VncPixelFormat format;
bool rgb565;
};
@@ -50,6 +51,7 @@ TEST_P(VncPixelFormatSelection, SetPixelFormatMatchesTheDisplay) {
const auto &param = GetParam();
FakeDisplay display(64, 48, param.type);
Session s(&display);
s.client.set_pixel_format(param.format);
s.attach();
s.deliver(handshake_38(64, 48));
Bytes tx = s.take();
@@ -59,10 +61,13 @@ TEST_P(VncPixelFormatSelection, SetPixelFormatMatchesTheDisplay) {
EXPECT_EQ(s.client.bpp(), param.rgb565 ? 2 : 1);
}
INSTANTIATE_TEST_SUITE_P(Formats, VncPixelFormatSelection,
::testing::Values(PixelFormatCase{display::DISPLAY_TYPE_COLOR, true},
PixelFormatCase{display::DISPLAY_TYPE_GRAYSCALE, true},
PixelFormatCase{display::DISPLAY_TYPE_BINARY, false}));
INSTANTIATE_TEST_SUITE_P(
Formats, VncPixelFormatSelection,
::testing::Values(PixelFormatCase{display::DISPLAY_TYPE_COLOR, VncPixelFormat::VNC_PIXEL_FORMAT_AUTO, true},
PixelFormatCase{display::DISPLAY_TYPE_GRAYSCALE, VncPixelFormat::VNC_PIXEL_FORMAT_AUTO, true},
PixelFormatCase{display::DISPLAY_TYPE_BINARY, VncPixelFormat::VNC_PIXEL_FORMAT_AUTO, false},
PixelFormatCase{display::DISPLAY_TYPE_COLOR, VncPixelFormat::VNC_PIXEL_FORMAT_RGB332, false},
PixelFormatCase{display::DISPLAY_TYPE_BINARY, VncPixelFormat::VNC_PIXEL_FORMAT_RGB565, true}));
TEST(VncHandshake, Rfb37NoneHasNoSecurityResult) {
FakeDisplay display(64, 48);
@@ -249,6 +254,8 @@ TEST(VncHandshake, DumpConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.client.dump_config();
s.client.set_pixel_format(VncPixelFormat::VNC_PIXEL_FORMAT_RGB332);
s.client.dump_config();
}
TEST(VncHandshake, HandshakeTimeout) {
+91 -4
View File
@@ -9,6 +9,7 @@ static Bytes frame(uint16_t x = 0, uint16_t pixel = 0x1234) {
}
static const Bytes INCREMENTAL = fbur(true, 64, 48);
static const Bytes FULL = fbur(false, 64, 48);
// The session starts at t=1000, when the first (full) request goes out.
class VncPacing : public ::testing::Test {
@@ -148,6 +149,40 @@ TEST_F(VncPacing, CursorOnlyFrameNeverRefreshes) {
EXPECT_EQ(d_.updates(), 0);
}
TEST_F(VncPacing, RefreshSendsAFullRequestEvenWithOneOutstanding) {
set_now(1100);
s().client.refresh();
s().loop();
EXPECT_EQ(hex(s().take()), hex(FULL));
s().run_for(1000);
EXPECT_EQ(s().take().size(), 0u);
}
TEST_F(VncPacing, RefreshKeepsThe50msFloor) {
set_now(1100);
s().deliver(frame());
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
s().client.refresh();
s().at(1149);
EXPECT_EQ(s().take().size(), 0u);
s().at(1150);
EXPECT_EQ(hex(s().take()), hex(FULL));
}
TEST_F(VncPacing, RefreshDuringAFrameWaitsForItsEnd) {
Bytes f = Rfb().fbu(1).raw_rect(0, 0, 4, 1, {1, 2, 3, 4}).bytes;
set_now(1100);
s().deliver(Bytes(f.begin(), f.end() - 2));
s().client.refresh();
s().run_for(500);
EXPECT_EQ(s().take().size(), 0u);
s().deliver(Bytes(f.end() - 2, f.end()));
// Only the full request: the incremental one it replaces is not sent as well.
EXPECT_EQ(hex(s().take()), hex(FULL));
s().run_for(500);
EXPECT_EQ(s().take().size(), 0u);
}
TEST_F(VncPacing, HeartbeatAfterAMinuteOfSilence) {
set_now(1100);
s().deliver(frame());
@@ -233,6 +268,22 @@ TEST_F(VncPacingParked, BusyDisplayHoldsDrawsAndRequests) {
EXPECT_EQ(d_.updates(), 2);
}
// The pass that starts a refresh already sees the panel busy, so it sends no request while the refresh runs.
TEST_F(VncPacingParked, NoRequestInThePassThatStartsARefresh) {
set_now(1100);
s().deliver(frame());
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
// vnc.refresh from a button handled earlier in the same main loop iteration.
s().client.refresh();
s().at(1300);
ASSERT_EQ(d_.updates(), 1);
ASSERT_TRUE(d_.busy());
EXPECT_EQ(s().take().size(), 0u) << "request sent while the panel refreshes";
d_.finish();
s().tick(10);
EXPECT_EQ(hex(s().take()), hex(FULL));
}
TEST(VncPacingParkedDelay, NoRequestInTheRefreshPassWithZeroDelay) {
FakeParkingDisplay d;
Session s(&d);
@@ -266,16 +317,16 @@ TEST_F(VncPacingParked, FailedDisplayEndsTheSessionAndStops) {
}
TEST_F(VncPacingParked, NoRequestWhileBusyBetweenFrames) {
// Inside the 50 ms floor, so the next request is still due when the panel turns busy.
set_now(1010);
set_now(1100);
s().deliver(Rfb().fbu(0));
EXPECT_EQ(s().take().size(), 0u);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
d_.external_update();
s().client.refresh();
s().run_for(2000);
EXPECT_EQ(s().take().size(), 0u);
d_.finish();
s().tick(10);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
EXPECT_EQ(hex(s().take()), hex(FULL));
}
TEST_F(VncPacingParked, ExternalUpdateMidFrameStopsReading) {
@@ -310,6 +361,9 @@ template<typename D> static void runs_frames_without_deadlock() {
EXPECT_EQ(d.updates(), i + 1) << "frame " << i;
EXPECT_EQ(d.pixel(i * 4, 0), expect_px(0x1000 + i, 2));
}
s.client.refresh();
s.tick(100);
EXPECT_EQ(hex(s.take()), hex(FULL));
}
TEST(VncPacingDisplays, PlainDisplay) { runs_frames_without_deadlock<FakeDisplay>(); }
@@ -368,6 +422,39 @@ TEST_F(VncPacingParked, DisplayBusyTooLongSetsAWarningUntilItParks) {
EXPECT_FALSE(s().client.status_has_warning());
}
// A refresh the driver gave up on (its warning is set when it parks) is retried once; a retry that fails
// too is not repeated until the driver finishes a refresh cleanly.
TEST_F(VncPacingParked, AbandonedRefreshIsRetriedOnce) {
set_now(1100);
s().deliver(frame());
s().at(1300);
ASSERT_EQ(d_.updates(), 1);
d_.status_set_warning(LOG_STR("Busy pin timeout"));
d_.finish();
s().tick(10);
EXPECT_TRUE(s().client.refresh_pending());
s().run_for(300);
EXPECT_EQ(d_.updates(), 2);
d_.finish();
s().run_for(3000);
EXPECT_EQ(d_.updates(), 2) << "a failed retry was retried again";
// A clean refresh arms the retry again
d_.status_clear_warning();
s().deliver(frame(16, 0x4321));
s().run_for(300);
ASSERT_EQ(d_.updates(), 3);
d_.finish();
s().tick(10);
d_.status_set_warning(LOG_STR("Busy pin timeout"));
s().deliver(frame(32, 0x5678));
s().run_for(300);
ASSERT_EQ(d_.updates(), 4);
d_.finish();
s().tick(10);
EXPECT_TRUE(s().client.refresh_pending());
}
TEST_F(VncPacingParked, DisplayParkingKeepsAConnectionWarning) {
d_.external_update();
s().run_for(120000, 1000);
@@ -0,0 +1,360 @@
#include "vnc_test_helpers.h"
#ifdef USE_HOST
namespace esphome::vnc::testing {
struct Tile {
uint16_t x;
uint16_t y;
uint16_t colour;
};
/// One update with a 16x16 hextile rect per tile, each a solid background.
static Bytes tiles(std::initializer_list<Tile> list) {
Rfb f;
f.fbu(static_cast<uint16_t>(list.size()));
for (const auto &t : list) {
f.rect(t.x, t.y, 16, 16, HEXTILE).u8(0x02).px(t.colour);
}
return f.bytes;
}
/// One update covering the whole w x h screen with solid tiles.
static Bytes screen(uint16_t w, uint16_t h, uint16_t seed) {
Rfb f;
f.fbu(1).rect(0, 0, w, h, HEXTILE);
const int count = ((w + 15) / 16) * ((h + 15) / 16);
for (int i = 0; i < count; i++) {
f.u8(0x02).px(static_cast<uint16_t>(seed + i * 0x0841));
}
return f.bytes;
}
static Bytes off_grid(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t colour) {
return Rfb().fbu(1).rect(x, y, w, h, HEXTILE).u8(0x02).px(colour).bytes;
}
static int count_draws(Session &s, const FakeDisplay &d, const Bytes &bytes) {
const int before = d.draw_calls();
s.deliver(bytes);
return d.draw_calls() - before;
}
class VncTileCache : public VncSessionTest {
protected:
void SetUp() override { this->start(this->d_, 64, 48); }
int draws(const Bytes &bytes) { return count_draws(s(), this->d_, bytes); }
void reconnect() {
s().close_peer();
s().loop();
ASSERT_FALSE(s().client.is_connected());
s().start(64, 48);
set_now(now_ms() + 100);
}
FakeDisplay d_;
};
TEST_F(VncTileCache, UnchangedTileIsNotDrawnAgain) {
const Bytes f = tiles({{16, 16, 0x1234}});
EXPECT_EQ(this->draws(f), 1);
s().run_for(300);
ASSERT_EQ(d_.updates(), 1);
s().take();
EXPECT_EQ(this->draws(f), 0);
EXPECT_FALSE(s().client.refresh_pending());
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
s().run_for(2000);
EXPECT_EQ(d_.updates(), 1);
EXPECT_TRUE(solid(d_, 16, 16, 16, 16, 0x1234));
}
TEST_F(VncTileCache, OnlyChangedTilesAreDrawn) {
EXPECT_EQ(this->draws(tiles({{0, 0, 0x1111}, {16, 0, 0x2222}, {32, 0, 0x3333}})), 3);
s().run_for(300);
EXPECT_EQ(this->draws(tiles({{0, 0, 0x1111}, {16, 0, 0x4444}, {32, 0, 0x3333}})), 1);
EXPECT_TRUE(solid(d_, 16, 0, 16, 16, 0x4444));
s().run_for(300);
EXPECT_EQ(d_.updates(), 2);
}
TEST_F(VncTileCache, SkippedFrameDoesNotPostponeThePendingRefresh) {
const Bytes f = tiles({{0, 0, 0x1111}});
set_now(1200);
ASSERT_EQ(this->draws(f), 1);
set_now(1350);
EXPECT_EQ(this->draws(f), 0);
s().at(1399);
EXPECT_EQ(d_.updates(), 0);
s().at(1400);
EXPECT_EQ(d_.updates(), 1);
}
TEST_F(VncTileCache, SameBytesInAnotherShapeAreDrawn) {
const Bytes wide = Rfb().fbu(1).rect(0, 0, 16, 8, HEXTILE).u8(0x02).px(0x1234).bytes;
const Bytes tall = Rfb().fbu(1).rect(0, 0, 8, 16, HEXTILE).u8(0x02).px(0x1234).bytes;
EXPECT_EQ(this->draws(wide), 1);
EXPECT_EQ(this->draws(tall), 1);
EXPECT_TRUE(solid(d_, 0, 8, 8, 8, 0x1234));
EXPECT_EQ(this->draws(tall), 0);
}
TEST_F(VncTileCache, RawRectForgetsOnlyTheCellsItCovers) {
const Bytes f = tiles({{0, 16, 0x1111}, {16, 16, 0x2222}, {32, 32, 0x3333}});
ASSERT_EQ(this->draws(f), 3);
// Ends on the last column of the cell at 0,16.
s().deliver(Rfb().fbu(1).raw_rect(0, 20, 16, 1, std::vector<uint16_t>(16, 0xF800)));
ASSERT_EQ(d_.pixel(15, 20), expect_px(0xF800, 2));
EXPECT_EQ(this->draws(f), 1);
EXPECT_TRUE(solid(d_, 0, 16, 16, 16, 0x1111));
s().deliver(Rfb().fbu(1).raw_rect(20, 20, 1, 1, {0xF800}));
ASSERT_EQ(d_.pixel(20, 20), expect_px(0xF800, 2));
EXPECT_EQ(this->draws(f), 1);
EXPECT_TRUE(solid(d_, 16, 16, 16, 16, 0x2222));
}
TEST_F(VncTileCache, OffGridTileForgetsTheCellsItCovers) {
const Bytes f = tiles({{0, 0, 0x1111}, {16, 0, 0x2222}, {0, 16, 0x3333}, {16, 16, 0x4444}, {32, 0, 0x5555}});
ASSERT_EQ(this->draws(f), 5);
const Bytes across = off_grid(8, 8, 9, 9, 0xF800);
// Never skipped itself.
EXPECT_EQ(this->draws(across), 1);
EXPECT_EQ(this->draws(across), 1);
EXPECT_EQ(this->draws(f), 4);
EXPECT_TRUE(solid(d_, 0, 0, 16, 16, 0x1111));
EXPECT_TRUE(solid(d_, 16, 16, 16, 16, 0x4444));
// Ends on the last pixel of the first cell.
EXPECT_EQ(this->draws(off_grid(8, 8, 8, 8, 0xF800)), 1);
EXPECT_EQ(this->draws(f), 1);
EXPECT_TRUE(solid(d_, 0, 0, 16, 16, 0x1111));
}
TEST_F(VncTileCache, RefreshForgetsEveryTile) {
const Bytes f = tiles({{16, 16, 0x1234}});
ASSERT_EQ(this->draws(f), 1);
s().run_for(300);
ASSERT_EQ(d_.updates(), 1);
s().take();
s().client.refresh();
s().loop();
EXPECT_EQ(hex(s().take()), hex(fbur(false, 64, 48)));
EXPECT_EQ(this->draws(f), 1);
s().run_for(300);
EXPECT_EQ(d_.updates(), 2);
}
TEST_F(VncTileCache, RefreshedTilesStayCachedInTheNextSession) {
const Bytes full = screen(64, 48, 0x1000);
ASSERT_EQ(this->draws(full), 12);
s().run_for(300);
ASSERT_EQ(d_.updates(), 1);
this->reconnect();
EXPECT_EQ(this->draws(full), 0);
s().run_for(300);
EXPECT_EQ(d_.updates(), 1);
}
TEST_F(VncTileCache, DropBeforeTheRefreshRedrawsInTheNextSession) {
const Bytes full = screen(64, 48, 0x1000);
ASSERT_EQ(this->draws(full), 12);
s().tick(100);
ASSERT_TRUE(s().client.refresh_pending());
this->reconnect();
EXPECT_EQ(d_.updates(), 0);
EXPECT_EQ(this->draws(full), 12);
s().run_for(300);
EXPECT_EQ(d_.updates(), 1);
}
TEST_F(VncTileCache, DropMidFrameRedrawsInTheNextSession) {
ASSERT_EQ(this->draws(screen(64, 48, 0x1000)), 12);
s().run_for(300);
ASSERT_EQ(d_.updates(), 1);
const Bytes next = screen(64, 48, 0x2000);
// The update and rect headers, then six of the twelve tiles.
ASSERT_EQ(this->draws(Bytes(next.begin(), next.begin() + 4 + 12 + 6 * 3)), 6);
ASSERT_TRUE(s().client.in_update());
ASSERT_FALSE(s().client.refresh_pending());
this->reconnect();
EXPECT_EQ(this->draws(next), 12);
s().run_for(300);
EXPECT_EQ(d_.updates(), 2);
}
enum class SessionEnd { PEER_CLOSED, PROTOCOL_ERROR };
class VncTileCacheSessionEnd : public VncTileCache, public ::testing::WithParamInterface<SessionEnd> {};
TEST_P(VncTileCacheSessionEnd, PixelsNotYetShownAreDrawnAgain) {
const Bytes full = screen(64, 48, 0x1000);
ASSERT_EQ(this->draws(full), 12);
switch (GetParam()) {
case SessionEnd::PEER_CLOSED:
s().close_peer();
s().loop();
break;
case SessionEnd::PROTOCOL_ERROR:
s().deliver(Rfb().u8(7));
break;
}
ASSERT_FALSE(s().client.is_connected());
s().start(64, 48);
set_now(now_ms() + 100);
EXPECT_EQ(this->draws(full), 12);
s().run_for(300);
EXPECT_EQ(d_.updates(), 1);
}
INSTANTIATE_TEST_SUITE_P(Ends, VncTileCacheSessionEnd,
::testing::Values(SessionEnd::PEER_CLOSED, SessionEnd::PROTOCOL_ERROR));
// Bit 7 of every fourth byte only reached bit 31 of the old word-wise hash, so an even number of flips cancelled.
TEST(VncTileCacheHash, Rgb332TilesDifferingInEveryFourthByteAreBothDrawn) {
FakeDisplay d(64, 48, display::DISPLAY_TYPE_BINARY);
Session s(&d);
s.start(64, 48);
ASSERT_EQ(s.client.bpp(), 1);
set_now(now_ms() + 100);
const Bytes a(256, 0x00);
Bytes b = a;
const int flips[][2] = {{3, 0}, {7, 2}, {11, 4}, {15, 6}};
for (const auto &f : flips) {
b[f[1] * 16 + f[0]] ^= 0x80;
}
auto raw_tile = [](const Bytes &px) { return Rfb().fbu(1).rect(16, 16, 16, 16, HEXTILE).u8(0x01).raw(px).bytes; };
EXPECT_EQ(count_draws(s, d, raw_tile(a)), 1);
EXPECT_EQ(count_draws(s, d, raw_tile(b)), 1);
for (const auto &f : flips) {
EXPECT_EQ(d.pixel(16 + f[0], 16 + f[1]), expect_px(0x80, 1)) << f[0] << "," << f[1];
}
EXPECT_EQ(count_draws(s, d, raw_tile(a)), 1);
for (const auto &f : flips) {
EXPECT_EQ(d.pixel(16 + f[0], 16 + f[1]), expect_px(0x00, 1)) << f[0] << "," << f[1];
}
}
// --- The display size can change between sessions; the cache was sized for 64x48 (12 cells) at setup.
class VncTileCacheLayout : public VncSessionTest {
protected:
void SetUp() override {
this->session_ = std::make_unique<Session>(&this->d_);
ASSERT_EQ(c().hash_capacity(), 12u);
}
TestVncClient &c() { return s().client; }
int draws(const Bytes &bytes) { return count_draws(s(), this->d_, bytes); }
/// Ends any session, resizes the display and starts a session with a server of the same size.
void session(int w, int h) {
if (c().is_connected()) {
s().close_peer();
s().loop();
}
this->d_.resize(w, h);
s().start(w, h);
set_now(now_ms() + 100);
}
FakeDisplay d_{64, 48};
};
TEST_F(VncTileCacheLayout, EveryLayoutWithinTheCellsUsesTheCache) {
const int sizes[][2] = {{64, 48}, {48, 64}, {96, 32}, {32, 96}, {192, 16}, {16, 192}, {50, 40}};
for (const auto &size : sizes) {
const int w = size[0];
const int h = size[1];
this->session(w, h);
EXPECT_EQ(c().hash_cols(), (w + 15) / 16) << w << "x" << h;
EXPECT_EQ(c().hash_rows(), (h + 15) / 16) << w << "x" << h;
const Bytes full = screen(w, h, 0x1000);
EXPECT_EQ(this->draws(full), 12) << w << "x" << h;
EXPECT_EQ(this->draws(full), 0) << w << "x" << h;
s().run_for(300);
}
EXPECT_EQ(d_.out_of_bounds(), 0);
}
TEST_F(VncTileCacheLayout, RotationForgetsTheTiles) {
this->session(64, 48);
const Bytes tile = tiles({{16, 0, 0x1234}});
ASSERT_EQ(this->draws(tile), 1);
s().run_for(300);
this->session(48, 64);
// Same place, same pixels, but the panel turned underneath them.
EXPECT_EQ(this->draws(tile), 1);
EXPECT_EQ(this->draws(tile), 0);
}
TEST_F(VncTileCacheLayout, HalfTurnForgetsTheTiles) {
this->session(64, 48);
const Bytes full = screen(64, 48, 0x1000);
ASSERT_EQ(this->draws(full), 12);
s().run_for(300);
// A half turn keeps the cell grid but puts every tile somewhere else on the panel.
d_.set_rotation(display::DISPLAY_ROTATION_180_DEGREES);
this->session(64, 48);
EXPECT_EQ(this->draws(full), 12);
EXPECT_EQ(this->draws(full), 0);
}
TEST_F(VncTileCacheLayout, RotationDuringASessionReconnects) {
this->session(64, 48);
ASSERT_EQ(this->draws(screen(64, 48, 0x1000)), 12);
s().run_for(300);
d_.set_rotation(display::DISPLAY_ROTATION_180_DEGREES);
s().tick(10);
EXPECT_EQ(c().state(), VncState::VNC_STATE_IDLE);
EXPECT_FALSE(c().link().connected());
}
TEST_F(VncTileCacheLayout, TooManyCellsTurnsTheCacheOff) {
this->session(64, 48);
const Bytes tile = tiles({{0, 0, 0x1234}});
ASSERT_EQ(this->draws(tile), 1);
s().run_for(300);
this->session(80, 64);
EXPECT_EQ(c().hash_cols(), 0);
const Bytes full = screen(80, 64, 0x2000);
EXPECT_EQ(this->draws(full), 20);
EXPECT_EQ(this->draws(full), 20);
s().deliver(Rfb()
.fbu(2)
.raw_rect(70, 60, 10, 4, std::vector<uint16_t>(40, 0x1111))
.rect(72, 56, 8, 8, HEXTILE)
.u8(0x02)
.px(0x2222));
EXPECT_TRUE(c().is_connected());
s().run_for(300);
// The cache saw nothing of the last session, so it starts empty.
this->session(64, 48);
EXPECT_EQ(c().hash_cols(), 4);
EXPECT_EQ(this->draws(tile), 1);
EXPECT_EQ(this->draws(tile), 0);
EXPECT_EQ(d_.out_of_bounds(), 0);
}
TEST(VncTileCacheClip, ServerLargerThanTheDisplayUsesTheVisibleCells) {
FakeDisplay d(72, 48);
Session s(&d);
s.start(100, 80);
ASSERT_EQ(s.client.hash_capacity(), 15u);
EXPECT_EQ(s.client.hash_cols(), 5);
EXPECT_EQ(s.client.hash_rows(), 3);
set_now(now_ms() + 100);
// Six tiles: 48,32 is fully visible, 64,32 is cut at the right edge, the rest are off screen.
Rfb f;
f.fbu(1).rect(48, 32, 48, 32, HEXTILE);
for (int i = 0; i < 6; i++) {
f.u8(0x02).px(0x1000 + i);
}
EXPECT_EQ(count_draws(s, d, f.bytes), 2);
EXPECT_EQ(count_draws(s, d, f.bytes), 0);
EXPECT_TRUE(solid(d, 64, 32, 8, 16, 0x1001));
EXPECT_EQ(d.out_of_bounds(), 0);
}
} // namespace esphome::vnc::testing
#endif // USE_HOST
@@ -7,7 +7,8 @@ vnc:
interval:
- interval: 60s
then:
- vnc.refresh
- if:
condition: vnc.is_connected
then:
- logger.log: VNC is connected
- vnc.refresh
+39 -2
View File
@@ -119,6 +119,12 @@ class FakeDisplay : public display::Display {
int out_of_bounds() const { return this->out_of_bounds_; }
int draws_while_busy() const { return this->draws_while_busy_; }
void set_draw_delay_us(uint32_t us) { this->draw_delay_us_ = us; }
/// A new size for the next session, like a rotation changed at runtime. Clears the pixels.
void resize(int width, int height) {
this->width_ = width;
this->height_ = height;
this->pixels_.assign(static_cast<size_t>(width) * height, UNSET);
}
protected:
int width_;
@@ -259,6 +265,9 @@ class TestVncClient : public VncClient {
uint16_t vis_w() const { return this->vis_w_; }
uint16_t vis_h() const { return this->vis_h_; }
uint8_t bpp() const { return this->bpp_; }
uint16_t hash_cols() const { return this->hash_cols_; }
uint16_t hash_rows() const { return this->hash_rows_; }
uint32_t hash_capacity() const { return this->hash_capacity_; }
};
// ---------------------------------------------------------------------------
@@ -358,7 +367,7 @@ inline Bytes fbur(bool incremental, uint16_t w, uint16_t h) {
}
inline Bytes set_pixel_format_565() { return {0, 0, 0, 0, 16, 16, 0, 1, 0, 31, 0, 63, 0, 31, 11, 5, 0, 0, 0, 0}; }
inline Bytes set_pixel_format_332() { return {0, 0, 0, 0, 8, 8, 0, 1, 0, 7, 0, 7, 0, 3, 5, 2, 0, 0, 0, 0}; }
inline Bytes set_encodings() { return {2, 0, 0, 2, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; }
inline Bytes set_encodings() { return {2, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; }
inline Bytes version_reply(char minor) { return text(std::string("RFB 003.00") + minor + "\n"); }
/// Splits a stream of client messages that are all FramebufferUpdateRequests.
@@ -439,7 +448,8 @@ class Listener {
class Session {
public:
explicit Session(display::Display *display, uint32_t start_ms = 1000) : client(display) {
explicit Session(display::Display *display, uint32_t start_ms = 1000, bool big_endian = false)
: client(display, big_endian) {
signal(SIGPIPE, SIG_IGN);
set_now(start_ms);
this->client.set_host("127.0.0.1");
@@ -576,6 +586,33 @@ class Session {
size_t out_pos_{0};
};
inline constexpr int32_t HEXTILE = 5;
/// Whether the w x h block at x,y shows one RGB565 wire colour.
inline bool solid(const FakeDisplay &d, int x, int y, int w, int h, uint16_t colour) {
for (int row = y; row < y + h; row++) {
for (int col = x; col < x + w; col++) {
if (d.pixel(col, row) != expect_px(colour, 2)) {
return false;
}
}
}
return true;
}
class VncSessionTest : public ::testing::Test {
protected:
void TearDown() override { this->session_.reset(); }
/// Starts a session, then moves past the 50 ms request spacing so the next request goes out as soon as a frame ends.
void start(FakeDisplay &display, uint16_t fb_w, uint16_t fb_h) {
this->session_ = std::make_unique<Session>(&display);
this->session_->start(fb_w, fb_h);
set_now(now_ms() + 100);
}
Session &s() { return *this->session_; }
std::unique_ptr<Session> session_;
};
} // namespace esphome::vnc::testing
#endif // USE_HOST
+7 -9
View File
@@ -239,11 +239,10 @@ await initial_state_helper.wait_for_initial_states()
# Now perform your test actions - on_state will only receive new changes
# ... trigger state changes ...
# Wait for expected state
try:
result = await asyncio.wait_for(state_future, timeout=5.0)
except asyncio.TimeoutError:
pytest.fail(f"Expected state not received. Got: {list(states.values())}")
# Wait for expected state; on timeout the test fails and shows the states received
result = await wait_or_fail(
state_future, 5.0, "Expected state not received", states=states
)
```
**Legacy: Manual State Tracking**
@@ -268,10 +267,9 @@ def on_state(state: EntityState) -> None:
client.subscribe_states(on_state)
# Wait for state with timeout
try:
result = await asyncio.wait_for(state_future, timeout=5.0)
except asyncio.TimeoutError:
pytest.fail(f"Expected state not received. Got: {list(states.values())}")
result = await wait_or_fail(
state_future, 5.0, "Expected state not received", states=states
)
```
##### Service Execution Pattern
@@ -12,6 +12,9 @@ api:
- snapshot.take:
id: disp
filename: !lambda return filename;
- action: vnc_refresh
then:
- vnc.refresh: remote
logger:
level: DEBUG
+130 -4
View File
@@ -5,7 +5,7 @@ handshake for RFB 3.3, 3.7 and 3.8, the same security lists and failure messages
connection that closes on any client message type it does not know.
The framebuffer is an RGB888 image the test draws into. Updates are translated into the pixel
format the client asked for and encoded as Raw or a RichCursor shape. Every client
format the client asked for and encoded as Raw, Hextile or a RichCursor shape. Every client
message is recorded, and the test decides when to answer: ``wait_for("fbur")`` then
``send_update(...)``.
"""
@@ -13,6 +13,7 @@ message is recorded, and the test decides when to answer: ``wait_for("fbur")`` t
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Callable, Iterable, Sequence
import contextlib
from dataclasses import asdict, dataclass
@@ -28,8 +29,15 @@ SECURITY_NONE = 1
SECURITY_TIGHT = 16
ENCODING_RAW = 0
ENCODING_HEXTILE = 5
ENCODING_RICH_CURSOR = -239
HEXTILE_RAW = 0x01
HEXTILE_BACKGROUND = 0x02
HEXTILE_FOREGROUND = 0x04
HEXTILE_ANY_SUBRECTS = 0x08
HEXTILE_SUBRECTS_COLOURED = 0x10
#: ``chunk`` value that writes in random 1-7 byte pieces.
HOSTILE_CHUNK = -1
@@ -38,12 +46,13 @@ MAX_CLIENT_CUT_TEXT = 1 << 20
RGB = tuple[int, int, int]
Encoding = Literal["raw", "rich-cursor"]
Encoding = Literal["raw", "hextile", "hextile-raw-tiles", "rich-cursor"]
Rect = tuple[int, int, int, int, Encoding]
Message = dict[str, Any]
_PIXEL_FORMAT = struct.Struct(">BBBBHHHBBB3x")
_VERSION_RE = re.compile(rb"RFB (\d{3})\.(\d{3})\n")
_TILE = 16
@dataclass(frozen=True)
@@ -312,17 +321,24 @@ class FakeRfbServer:
rects: Sequence[Rect],
*,
pixel_format: PixelFormat | None = None,
always_send_bg: bool = False,
raw_tile_flags: int = HEXTILE_RAW,
) -> bytes:
"""Encode one FramebufferUpdate from the image.
Each rect is ``(x, y, w, h, encoding)``. For ``rich-cursor`` x and y are the hotspot
and the shape is a fixed pattern.
and the shape is a fixed pattern. ``hextile-raw-tiles`` sends every tile raw.
Args:
rects: The rectangles, in order.
pixel_format: Format to encode in; defaults to the current client's.
always_send_bg: Send the Hextile background on every tile, even when unchanged.
raw_tile_flags: Subencoding byte of raw Hextile tiles. Bits other than Raw are
ignored by decoders, so 0x1F is valid.
"""
fmt = pixel_format or self.pixel_format
if not raw_tile_flags & HEXTILE_RAW:
raise ValueError("raw_tile_flags must include the Raw bit")
out = bytearray(struct.pack(">BxH", 0, len(rects)))
for x, y, w, h, encoding in rects:
if encoding == "rich-cursor":
@@ -334,6 +350,15 @@ class FakeRfbServer:
if encoding == "raw":
out += struct.pack(">HHHHi", x, y, w, h, ENCODING_RAW)
out += _pixels(fmt, (v for row in values for v in row))
elif encoding in ("hextile", "hextile-raw-tiles"):
out += struct.pack(">HHHHi", x, y, w, h, ENCODING_HEXTILE)
out += _hextile(
fmt,
values,
force_raw=encoding == "hextile-raw-tiles",
always_send_bg=always_send_bg,
raw_tile_flags=raw_tile_flags,
)
else:
raise ValueError(f"unknown encoding {encoding!r}")
return bytes(out)
@@ -343,6 +368,8 @@ class FakeRfbServer:
rects: Sequence[Rect],
chunk: int | None = None,
*,
always_send_bg: bool = False,
raw_tile_flags: int = HEXTILE_RAW,
delay: float = 0.0,
) -> None:
"""Send one FramebufferUpdate built by ``build_update()``.
@@ -351,10 +378,17 @@ class FakeRfbServer:
rects: The rectangles, in order.
chunk: Split the write into pieces of at most this many bytes; ``HOSTILE_CHUNK``
uses seeded random 1-7 byte pieces.
always_send_bg: See ``build_update()``.
raw_tile_flags: See ``build_update()``.
delay: Sleep between pieces.
"""
session = self._require_session()
data = self.build_update(rects, pixel_format=session.pixel_format)
data = self.build_update(
rects,
pixel_format=session.pixel_format,
always_send_bg=always_send_bg,
raw_tile_flags=raw_tile_flags,
)
await self._write(session, data, chunk, delay)
async def send_cut_text(
@@ -627,3 +661,95 @@ def _cursor_shape(fmt: PixelFormat, w: int, h: int) -> bytes:
light if (x + y) % 2 else dark for y in range(h) for x in range(w)
)
return pixels + b"\x5a" * (((w + 7) // 8) * h)
def _subrects(tile: list[list[int]], bg: int) -> list[tuple[int, int, int, int, int]]:
"""Cover every non-background pixel with same-coloured rects, greedily."""
height, width = len(tile), len(tile[0])
done = [[False] * width for _ in range(height)]
rects = []
for y in range(height):
for x in range(width):
colour = tile[y][x]
if colour == bg or done[y][x]:
continue
right = x
while (
right + 1 < width
and tile[y][right + 1] == colour
and not done[y][right + 1]
):
right += 1
bottom = y
while bottom + 1 < height and all(
tile[bottom + 1][i] == colour and not done[bottom + 1][i]
for i in range(x, right + 1)
):
bottom += 1
for line in range(y, bottom + 1):
done[line][x : right + 1] = [True] * (right + 1 - x)
rects.append((x, y, right + 1 - x, bottom + 1 - y, colour))
return rects
def _hextile(
fmt: PixelFormat,
values: list[list[int]],
*,
force_raw: bool,
always_send_bg: bool,
raw_tile_flags: int,
) -> bytes:
"""Hextile-encode a region given as rows of pixel values.
Background and foreground carry over between tiles, but never past a raw tile or a
coloured-subrect tile, as libvncserver's encoder does.
"""
out = bytearray()
height = len(values)
width = len(values[0]) if values else 0
bg: int | None = None
fg: int | None = None
for ty in range(0, height, _TILE):
for tx in range(0, width, _TILE):
tile = [row[tx : tx + _TILE] for row in values[ty : ty + _TILE]]
flat = [v for row in tile for v in row]
raw_size = len(flat) * fmt.bytes_per_pixel
if not force_raw:
counts = Counter(flat)
tile_bg = counts.most_common(1)[0][0]
flags = 0
body = bytearray()
if tile_bg != bg or always_send_bg:
flags |= HEXTILE_BACKGROUND
body += fmt.to_bytes(tile_bg)
if len(counts) == 1:
out.append(flags)
out += body
bg = tile_bg
continue
subrects = _subrects(tile, tile_bg)
coloured = len(counts) > 2
flags |= HEXTILE_ANY_SUBRECTS
if coloured:
flags |= HEXTILE_SUBRECTS_COLOURED
tile_fg = None
else:
tile_fg = next(c for c in counts if c != tile_bg)
if tile_fg != fg:
flags |= HEXTILE_FOREGROUND
body += fmt.to_bytes(tile_fg)
body.append(len(subrects))
for x, y, w, h, colour in subrects:
if coloured:
body += fmt.to_bytes(colour)
body += bytes([(x << 4) | y, ((w - 1) << 4) | (h - 1)])
if len(body) < raw_size:
out.append(flags)
out += body
bg, fg = tile_bg, tile_fg
continue
out.append(raw_tile_flags)
out += _pixels(fmt, flat)
bg = fg = None
return bytes(out)
@@ -2,12 +2,12 @@
from __future__ import annotations
import asyncio
import collections
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -23,54 +23,41 @@ async def test_action_concurrent_reentry(
This test runs a script in parallel with varying arguments and verifies if
each script keeps its original argument throughout its execution
"""
test_complete = asyncio.Event()
waiter = LineWaiter()
expected = {0, 1, 2, 3, 4}
# Patterns to match in logs
after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)")
after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)")
after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)")
in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)")
after_while_pattern = re.compile(r"AFTER while ARG (\d+)")
in_while_pattern = re.compile(r"IN while ARG (\d+)")
def captures(pattern: str) -> list[tuple[int, ...]]:
return [
tuple(map(int, m.groups()))
for line in waiter.lines
if (m := re.search(pattern, line))
]
after_wait_until_args = []
after_script_wait_args = []
after_while_args = []
in_while_args = []
after_repeat_args = []
in_repeat_args = collections.defaultdict(list)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if test_complete.is_set():
return
if mo := after_wait_until_pattern.search(line):
after_wait_until_args.append(int(mo.group(1)))
elif mo := after_script_wait_pattern.search(line):
after_script_wait_args.append(int(mo.group(1)))
elif mo := in_while_pattern.search(line):
in_while_args.append(int(mo.group(1)))
elif mo := after_while_pattern.search(line):
after_while_args.append(int(mo.group(1)))
elif mo := in_repeat_pattern.search(line):
in_repeat_args[int(mo.group(1))].append(int(mo.group(2)))
elif mo := after_repeat_pattern.search(line):
after_repeat_args.append(int(mo.group(1)))
if len(after_repeat_args) == len(expected):
test_complete.set()
def arg_list(pattern: str) -> list[int]:
return [arg for (arg,) in captures(pattern)]
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "action-concurrent-reentry")
# Wait for tests to complete with timeout
await wait_or_fail(test_complete, 8.0, "test timed out")
await wait_or_fail(
waiter.wait_for_each(*(f"AFTER repeat ARG {arg}" for arg in expected)),
8.0,
"test timed out",
)
after_wait_until_args = arg_list(r"AFTER wait_until ARG (\d+)")
after_script_wait_args = arg_list(r"AFTER script\.wait ARG (\d+)")
after_repeat_args = arg_list(r"AFTER repeat ARG (\d+)")
after_while_args = arg_list(r"AFTER while ARG (\d+)")
in_while_args = arg_list(r"IN while ARG (\d+)")
in_repeat_args = collections.defaultdict(list)
for iteration, arg in captures(r"IN repeat (\d+) ARG (\d+)"):
in_repeat_args[iteration].append(arg)
# order may change, but all args must be present
for args in in_repeat_args.values():
+5 -17
View File
@@ -2,13 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from esphome.helpers import fnv1_hash
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,20 +17,10 @@ async def test_api_action_metadata(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Action and argument metadata reach the client and the actions still run."""
loop = asyncio.get_running_loop()
buzzer_called = loop.create_future()
plain_called = loop.create_future()
buzzer_pattern = re.compile(r"Buzzer: two_short")
plain_pattern = re.compile(r"Plain action called")
def check_output(line: str) -> None:
if not buzzer_called.done() and buzzer_pattern.search(line):
buzzer_called.set_result(True)
elif not plain_called.done() and plain_pattern.search(line):
plain_called.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
@@ -72,5 +60,5 @@ async def test_api_action_metadata(
buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3}
)
await client.execute_service(plain, {"value": 1})
await asyncio.wait_for(buzzer_called, timeout=5.0)
await asyncio.wait_for(plain_called, timeout=5.0)
await waiter.wait_for("Buzzer: two_short", timeout=5.0)
await waiter.wait_for("Plain action called", timeout=5.0)
+15 -49
View File
@@ -5,13 +5,12 @@ Tests the supports_response modes: none, status, optional, only.
from __future__ import annotations
import asyncio
import json
import re
from aioesphomeapi import SupportsResponseType, UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -23,50 +22,11 @@ async def test_api_action_responses(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test API action response modes work correctly."""
loop = asyncio.get_running_loop()
# Track log messages for each action type
no_response_future = loop.create_future()
status_success_future = loop.create_future()
status_error_future = loop.create_future()
optional_response_future = loop.create_future()
only_response_future = loop.create_future()
nested_json_future = loop.create_future()
# Patterns to match in logs
no_response_pattern = re.compile(r"ACTION_NO_RESPONSE called with: test_message")
status_success_pattern = re.compile(
r"ACTION_STATUS_RESPONSE success \(call_id=\d+\)"
)
status_error_pattern = re.compile(r"ACTION_STATUS_RESPONSE error \(call_id=\d+\)")
optional_response_pattern = re.compile(
r"ACTION_OPTIONAL_RESPONSE \(call_id=\d+, return_response=\d+, value=42\)"
)
only_response_pattern = re.compile(
r"ACTION_ONLY_RESPONSE \(call_id=\d+, name=World\)"
)
nested_json_pattern = re.compile(r"ACTION_NESTED_JSON \(call_id=\d+\)")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not no_response_future.done() and no_response_pattern.search(line):
no_response_future.set_result(True)
elif not status_success_future.done() and status_success_pattern.search(line):
status_success_future.set_result(True)
elif not status_error_future.done() and status_error_pattern.search(line):
status_error_future.set_result(True)
elif not optional_response_future.done() and optional_response_pattern.search(
line
):
optional_response_future.set_result(True)
elif not only_response_future.done() and only_response_pattern.search(line):
only_response_future.set_result(True)
elif not nested_json_future.done() and nested_json_pattern.search(line):
nested_json_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -146,7 +106,9 @@ async def test_api_action_responses(
action_no_response, {"message": "test_message"}
)
assert response is None, "action_no_response should not return a response"
await asyncio.wait_for(no_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_NO_RESPONSE called with: test_message", timeout=5.0
)
# Test action_status_response with success (supports_response: status)
response = await client.execute_service(
@@ -154,7 +116,7 @@ async def test_api_action_responses(
{"should_succeed": True},
return_response=True,
)
await asyncio.wait_for(status_success_future, timeout=5.0)
await waiter.wait_for("ACTION_STATUS_RESPONSE success (call_id=", timeout=5.0)
assert response is not None, "Expected response for status action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -169,7 +131,7 @@ async def test_api_action_responses(
{"should_succeed": False},
return_response=True,
)
await asyncio.wait_for(status_error_future, timeout=5.0)
await waiter.wait_for("ACTION_STATUS_RESPONSE error (call_id=", timeout=5.0)
assert response is not None, "Expected response for status action"
assert response.success is False, (
f"Expected success=False, got {response.success}"
@@ -185,7 +147,9 @@ async def test_api_action_responses(
{"value": 42},
return_response=True,
)
await asyncio.wait_for(optional_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_OPTIONAL_RESPONSE (call_id=", "value=42)", timeout=5.0
)
assert response is not None, "Expected response for optional action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -205,7 +169,9 @@ async def test_api_action_responses(
{"name": "World"},
return_response=True,
)
await asyncio.wait_for(only_response_future, timeout=5.0)
await waiter.wait_for(
"ACTION_ONLY_RESPONSE (call_id=", "name=World)", timeout=5.0
)
assert response is not None, "Expected response for only action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
@@ -224,7 +190,7 @@ async def test_api_action_responses(
{},
return_response=True,
)
await asyncio.wait_for(nested_json_future, timeout=5.0)
await waiter.wait_for("ACTION_NESTED_JSON (call_id=", timeout=5.0)
assert response is not None, "Expected response for nested json action"
assert response.success is True, (
f"Expected success=True, got {response.success}"
+13 -55
View File
@@ -8,10 +8,10 @@ from __future__ import annotations
import asyncio
import contextlib
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -30,52 +30,11 @@ async def test_api_action_timeout(
2. Actions that exceed the timeout have their calls cleaned up
3. Late responses log a warning but don't crash
"""
loop = asyncio.get_running_loop()
# Track log messages
immediate_future = loop.create_future()
short_delay_responding_future = loop.create_future()
long_delay_starting_future = loop.create_future()
long_delay_responding_future = loop.create_future()
timeout_warning_future = loop.create_future()
# Patterns to match in logs
immediate_pattern = re.compile(r"ACTION_IMMEDIATE responding")
short_delay_responding_pattern = re.compile(r"ACTION_SHORT_DELAY responding")
long_delay_starting_pattern = re.compile(r"ACTION_LONG_DELAY starting")
long_delay_responding_pattern = re.compile(
r"ACTION_LONG_DELAY responding \(after timeout\)"
)
# This warning is logged when api.respond is called after the action call timed out
timeout_warning_pattern = re.compile(
r"Cannot send response: no active call found for action_call_id"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not immediate_future.done() and immediate_pattern.search(line):
immediate_future.set_result(True)
elif (
not short_delay_responding_future.done()
and short_delay_responding_pattern.search(line)
):
short_delay_responding_future.set_result(True)
elif (
not long_delay_starting_future.done()
and long_delay_starting_pattern.search(line)
):
long_delay_starting_future.set_result(True)
elif (
not long_delay_responding_future.done()
and long_delay_responding_pattern.search(line)
):
long_delay_responding_future.set_result(True)
elif not timeout_warning_future.done() and timeout_warning_pattern.search(line):
timeout_warning_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -98,7 +57,7 @@ async def test_api_action_timeout(
{},
return_response=True,
)
await asyncio.wait_for(immediate_future, timeout=1.0)
await waiter.wait_for("ACTION_IMMEDIATE responding", timeout=1.0)
assert response is not None, "Expected response for immediate action"
assert response.success is True
@@ -108,7 +67,7 @@ async def test_api_action_timeout(
{},
return_response=True,
)
await asyncio.wait_for(short_delay_responding_future, timeout=1.0)
await waiter.wait_for("ACTION_SHORT_DELAY responding", timeout=1.0)
assert response is not None, "Expected response for short delay action"
assert response.success is True
@@ -128,13 +87,18 @@ async def test_api_action_timeout(
)
# Wait for the action to start
await asyncio.wait_for(long_delay_starting_future, timeout=1.0)
await waiter.wait_for("ACTION_LONG_DELAY starting", timeout=1.0)
# Wait for the action to try to respond (after 1s delay)
await asyncio.wait_for(long_delay_responding_future, timeout=2.0)
await waiter.wait_for(
"ACTION_LONG_DELAY responding (after timeout)", timeout=2.0
)
# Wait for the warning log about no active call
await asyncio.wait_for(timeout_warning_future, timeout=1.0)
await waiter.wait_for(
"Cannot send response: no active call found for action_call_id",
timeout=1.0,
)
# The client task should complete (either with None response or timeout)
# Client timing out is acceptable - the server-side timeout already cleaned up the call
@@ -143,12 +107,6 @@ async def test_api_action_timeout(
# Verify the system is still functional after the timeout
# Call the immediate action again to prove cleanup worked
immediate_future_2 = loop.create_future()
def check_output_2(line: str) -> None:
if not immediate_future_2.done() and immediate_pattern.search(line):
immediate_future_2.set_result(True)
response = await client.execute_service(
action_immediate,
{},
@@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,45 +17,17 @@ async def test_api_conditional_memory(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test API triggers and services work correctly with conditional compilation."""
loop = asyncio.get_running_loop()
# Track log messages
connected_future = loop.create_future()
disconnected_future = loop.create_future()
no_clients_future = loop.create_future()
service_simple_future = loop.create_future()
service_args_future = loop.create_future()
# Patterns to match in logs
connected_pattern = re.compile(r"Client .* connected from")
disconnected_pattern = re.compile(r"Client .* disconnected from")
no_clients_pattern = re.compile(r"No clients remaining")
service_simple_pattern = re.compile(r"Simple service called")
service_args_pattern = re.compile(
r"Service called with: test_string, 123, 1, 42\.50"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
connected_future.set_result(True)
elif not disconnected_future.done() and disconnected_pattern.search(line):
disconnected_future.set_result(True)
elif not no_clients_future.done() and no_clients_pattern.search(line):
no_clients_future.set_result(True)
elif not service_simple_future.done() and service_simple_pattern.search(line):
service_simple_future.set_result(True)
elif not service_args_future.done() and service_args_pattern.search(line):
service_args_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with run_compiled(yaml_config, line_callback=check_output):
async with run_compiled(yaml_config, line_callback=waiter.callback):
async with api_client_connected() as client:
# Verify device info
await assert_device_name(client, "api-conditional-memory-test")
# Wait for connection log
await asyncio.wait_for(connected_future, timeout=5.0)
# The leading space keeps "disconnected from" from matching
await waiter.wait_for("Client", " connected from", timeout=5.0)
# List services
_, services = await client.list_entities_services()
@@ -85,7 +55,7 @@ async def test_api_conditional_memory(
await client.execute_service(simple_service, {})
# Wait for service log
await asyncio.wait_for(service_simple_future, timeout=5.0)
await waiter.wait_for("Simple service called", timeout=5.0)
# Call service with arguments
await client.execute_service(
@@ -99,11 +69,13 @@ async def test_api_conditional_memory(
)
# Wait for service with args log
await asyncio.wait_for(service_args_future, timeout=5.0)
await waiter.wait_for(
"Service called with: test_string, 123, 1, 42.50", timeout=5.0
)
# Client disconnected here, wait for disconnect log
await asyncio.wait_for(disconnected_future, timeout=5.0)
await waiter.wait_for("Client", "disconnected from", timeout=5.0)
# Verify fix for issue #11131: api.connected should be false in trigger
# when the last client disconnects
await asyncio.wait_for(no_clients_future, timeout=5.0)
await waiter.wait_for("No clients remaining", timeout=5.0)
+18 -53
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserServiceArgType
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -19,52 +17,11 @@ async def test_api_custom_services(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test CustomAPIDevice services work correctly with custom_services: true."""
loop = asyncio.get_running_loop()
# Track log messages
yaml_service_future = loop.create_future()
yaml_args_future = loop.create_future()
yaml_many_args_future = loop.create_future()
custom_service_future = loop.create_future()
custom_args_future = loop.create_future()
custom_arrays_future = loop.create_future()
ha_state_future = loop.create_future()
# Patterns to match in logs
yaml_service_pattern = re.compile(r"YAML service called")
yaml_args_pattern = re.compile(r"YAML service with args: 123, test_value")
yaml_many_args_pattern = re.compile(r"YAML service many args: 42, 3\.14, 1, hello")
custom_service_pattern = re.compile(r"Custom test service called!")
custom_args_pattern = re.compile(
r"Custom service called with: test_string, 456, 1, 78\.90"
)
custom_arrays_pattern = re.compile(
r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings"
)
ha_state_pattern = re.compile(
r"This subscription uses std::string API for backward compatibility"
)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not yaml_service_future.done() and yaml_service_pattern.search(line):
yaml_service_future.set_result(True)
elif not yaml_args_future.done() and yaml_args_pattern.search(line):
yaml_args_future.set_result(True)
elif not yaml_many_args_future.done() and yaml_many_args_pattern.search(line):
yaml_many_args_future.set_result(True)
elif not custom_service_future.done() and custom_service_pattern.search(line):
custom_service_future.set_result(True)
elif not custom_args_future.done() and custom_args_pattern.search(line):
custom_args_future.set_result(True)
elif not custom_arrays_future.done() and custom_arrays_pattern.search(line):
custom_arrays_future.set_result(True)
elif not ha_state_future.done() and ha_state_pattern.search(line):
ha_state_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -88,7 +45,7 @@ async def test_api_custom_services(
# Test YAML service
await client.execute_service(yaml_service, {})
await asyncio.wait_for(yaml_service_future, timeout=5.0)
await waiter.wait_for("YAML service called", timeout=5.0)
# Verify YAML service with args arguments
assert len(yaml_args_service.args) == 2
@@ -104,7 +61,7 @@ async def test_api_custom_services(
"my_string": "test_value",
},
)
await asyncio.wait_for(yaml_args_future, timeout=5.0)
await waiter.wait_for("YAML service with args: 123, test_value", timeout=5.0)
# Verify YAML service with many args arguments
assert len(yaml_many_args_service.args) == 4
@@ -126,11 +83,11 @@ async def test_api_custom_services(
"arg4": "hello",
},
)
await asyncio.wait_for(yaml_many_args_future, timeout=5.0)
await waiter.wait_for("YAML service many args: 42, 3.14, 1, hello", timeout=5.0)
# Test simple CustomAPIDevice service
await client.execute_service(custom_service, {})
await asyncio.wait_for(custom_service_future, timeout=5.0)
await waiter.wait_for("Custom test service called!", timeout=5.0)
# Verify custom_args_service arguments
assert len(custom_args_service.args) == 4
@@ -150,7 +107,9 @@ async def test_api_custom_services(
"arg_float": 78.9,
},
)
await asyncio.wait_for(custom_args_future, timeout=5.0)
await waiter.wait_for(
"Custom service called with: test_string, 456, 1, 78.90", timeout=5.0
)
# Verify array service arguments
assert len(custom_arrays_service.args) == 4
@@ -170,9 +129,15 @@ async def test_api_custom_services(
"string_array": ["hello", "world"],
},
)
await asyncio.wait_for(custom_arrays_future, timeout=5.0)
await waiter.wait_for(
"Array service called with 2 bools, 3 ints, 2 floats, 2 strings",
timeout=5.0,
)
# Test Home Assistant state subscription (std::string API backward compatibility)
# This verifies that custom_api_device.h can still use std::string overloads
client.send_home_assistant_state("sensor.custom_test", "", "42.5")
await asyncio.wait_for(ha_state_future, timeout=5.0)
await waiter.wait_for(
"This subscription uses std::string API for backward compatibility",
timeout=5.0,
)
+170 -151
View File
@@ -23,6 +23,7 @@ from .state_utils import (
wait_for_state,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -251,173 +252,191 @@ async def test_api_homeassistant(
await client.execute_service(trigger_service, {})
# Wait for all tests to complete with appropriate timeouts
try:
# Templated service test - the main bug fix
computed_value = await asyncio.wait_for(lambda_computed_future, timeout=5.0)
# Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191)
assert computed_value in ["191", "192"], (
f"Unexpected computed value: {computed_value}"
)
# Shown when a log line or service call does not arrive
ctx = {
"logged": log_lines,
"calls": lambda: [call.service for call in ha_service_calls],
}
# Templated service test - the main bug fix
computed_value = await wait_or_fail(
lambda_computed_future, 5.0, "no templated value", **ctx
)
# Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191)
assert computed_value in ["191", "192"], (
f"Unexpected computed value: {computed_value}"
)
# Check state reads - verify we received the mocked values
temp_line = await asyncio.wait_for(ha_temp_state_future, timeout=5.0)
assert "Current HA Temperature: 22.5" in temp_line
# Check state reads - verify we received the mocked values
temp_line = await wait_or_fail(
ha_temp_state_future, 5.0, "no HA temperature", **ctx
)
assert "Current HA Temperature: 22.5" in temp_line
humidity_line = await asyncio.wait_for(
ha_humidity_state_future, timeout=5.0
)
assert "Current HA Humidity: 65.0" in humidity_line
humidity_line = await wait_or_fail(
ha_humidity_state_future, 5.0, "no HA humidity", **ctx
)
assert "Current HA Humidity: 65.0" in humidity_line
motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0)
assert "Current HA Motion: ON" in motion_line
motion_line = await wait_or_fail(
ha_motion_state_future, 5.0, "no HA motion", **ctx
)
assert "Current HA Motion: ON" in motion_line
weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0)
assert "Current HA Weather: sunny" in weather_line
weather_line = await wait_or_fail(
ha_weather_state_future, 5.0, "no HA weather", **ctx
)
assert "Current HA Weather: sunny" in weather_line
# Number test
number_value = await asyncio.wait_for(ha_number_future, timeout=5.0)
assert number_value == "42.5", f"Unexpected number value: {number_value}"
# Number test
number_value = await wait_or_fail(
ha_number_future, 5.0, "no HA number value", **ctx
)
assert number_value == "42.5", f"Unexpected number value: {number_value}"
# Long attribute test - verify 300 chars weren't truncated to 255
long_attr_len = await asyncio.wait_for(long_attr_future, timeout=5.0)
assert long_attr_len == 300, (
f"Long attribute was truncated! Expected 300 chars, got {long_attr_len}. "
"This indicates the 256-byte truncation bug."
)
# Long attribute test - verify 300 chars weren't truncated to 255
long_attr_len = await wait_or_fail(
long_attr_future, 5.0, "no long attribute", **ctx
)
assert long_attr_len == 300, (
f"Long attribute was truncated! Expected 300 chars, got {long_attr_len}. "
"This indicates the 256-byte truncation bug."
)
# Wait for completion
await asyncio.wait_for(tests_complete_future, timeout=5.0)
# Wait for completion
await wait_or_fail(tests_complete_future, 5.0, "tests did not complete", **ctx)
# Now verify the protobuf messages
# 1. Basic service call
basic_call = await asyncio.wait_for(
service_call_futures["light.turn_off"], timeout=2.0
)
assert basic_call.service == "light.turn_off"
assert "entity_id" in basic_call.data, (
f"entity_id not found in data: {basic_call.data}"
)
assert basic_call.data["entity_id"] == "light.test_light", (
f"Wrong entity_id: {basic_call.data['entity_id']}"
)
# Now verify the protobuf messages
# 1. Basic service call
basic_call = await wait_or_fail(
service_call_futures["light.turn_off"], 2.0, "no light.turn_off call", **ctx
)
assert basic_call.service == "light.turn_off"
assert "entity_id" in basic_call.data, (
f"entity_id not found in data: {basic_call.data}"
)
assert basic_call.data["entity_id"] == "light.test_light", (
f"Wrong entity_id: {basic_call.data['entity_id']}"
)
# 2. Templated service call - verify the temporary string issue is fixed
templated_call = await asyncio.wait_for(
service_call_futures["light.turn_on"], timeout=2.0
)
assert templated_call.service == "light.turn_on"
# Check the computed brightness value
assert "brightness" in templated_call.data
assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100
# Check data_template
assert "color_name" in templated_call.data_template
assert templated_call.data_template["color_name"] == "test_value"
# Check variables
assert "transition" in templated_call.variables
assert templated_call.variables["transition"] == "2.5"
# 2. Templated service call - verify the temporary string issue is fixed
templated_call = await wait_or_fail(
service_call_futures["light.turn_on"], 2.0, "no light.turn_on call", **ctx
)
assert templated_call.service == "light.turn_on"
# Check the computed brightness value
assert "brightness" in templated_call.data
assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100
# Check data_template
assert "color_name" in templated_call.data_template
assert templated_call.data_template["color_name"] == "test_value"
# Check variables
assert "transition" in templated_call.variables
assert templated_call.variables["transition"] == "2.5"
# 3. Empty string service call
empty_call = await asyncio.wait_for(
service_call_futures["notify.test"], timeout=2.0
)
assert empty_call.service == "notify.test"
# Verify empty strings are properly handled
assert "title" in empty_call.data and empty_call.data["title"] == ""
assert (
"target" in empty_call.data_template
and empty_call.data_template["target"] == ""
)
assert (
"sound" in empty_call.variables and empty_call.variables["sound"] == ""
)
# 3. Empty string service call
empty_call = await wait_or_fail(
service_call_futures["notify.test"], 2.0, "no notify.test call", **ctx
)
assert empty_call.service == "notify.test"
# Verify empty strings are properly handled
assert "title" in empty_call.data and empty_call.data["title"] == ""
assert (
"target" in empty_call.data_template
and empty_call.data_template["target"] == ""
)
assert "sound" in empty_call.variables and empty_call.variables["sound"] == ""
# 4. Multiple fields service call
multi_call = await asyncio.wait_for(
service_call_futures["climate.set_temperature"], timeout=2.0
)
assert multi_call.service == "climate.set_temperature"
assert multi_call.data["temperature"] == "22"
assert multi_call.data["hvac_mode"] == "heat"
assert multi_call.data_template["target_temp_high"] == "24"
assert multi_call.variables["preset_mode"] == "comfort"
# 4. Multiple fields service call
multi_call = await wait_or_fail(
service_call_futures["climate.set_temperature"],
2.0,
"no climate.set_temperature call",
**ctx,
)
assert multi_call.service == "climate.set_temperature"
assert multi_call.data["temperature"] == "22"
assert multi_call.data["hvac_mode"] == "heat"
assert multi_call.data_template["target_temp_high"] == "24"
assert multi_call.variables["preset_mode"] == "comfort"
# 5. Complex lambda service call
complex_call = await asyncio.wait_for(
service_call_futures["script.test_script"], timeout=2.0
)
assert complex_call.service == "script.test_script"
assert complex_call.data["entity_id"] == "light.living_room"
assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99
# Check message includes sensor value
assert "message" in complex_call.data_template
assert "Sensor: 42.0" in complex_call.data_template["message"]
# 5. Complex lambda service call
complex_call = await wait_or_fail(
service_call_futures["script.test_script"],
2.0,
"no script.test_script call",
**ctx,
)
assert complex_call.service == "script.test_script"
assert complex_call.data["entity_id"] == "light.living_room"
assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99
# Check message includes sensor value
assert "message" in complex_call.data_template
assert "Sensor: 42.0" in complex_call.data_template["message"]
# 6. All empty service call
all_empty_call = await asyncio.wait_for(
service_call_futures["test.empty"], timeout=2.0
)
assert all_empty_call.service == "test.empty"
# All fields should be empty strings
assert all(v == "" for v in all_empty_call.data.values())
assert all(v == "" for v in all_empty_call.data_template.values())
assert all(v == "" for v in all_empty_call.variables.values())
# 6. All empty service call
all_empty_call = await wait_or_fail(
service_call_futures["test.empty"], 2.0, "no test.empty call", **ctx
)
assert all_empty_call.service == "test.empty"
# All fields should be empty strings
assert all(v == "" for v in all_empty_call.data.values())
assert all(v == "" for v in all_empty_call.data_template.values())
assert all(v == "" for v in all_empty_call.variables.values())
# 7. HA Number service call
number_call = await asyncio.wait_for(
service_call_futures["input_number.set_value"], timeout=2.0
)
assert number_call.service == "input_number.set_value"
assert number_call.data["entity_id"] == "input_number.test_number"
# The value might be formatted with trailing zeros
assert float(number_call.data["value"]) == 42.5
# 7. HA Number service call
number_call = await wait_or_fail(
service_call_futures["input_number.set_value"],
2.0,
"no input_number.set_value call",
**ctx,
)
assert number_call.service == "input_number.set_value"
assert number_call.data["entity_id"] == "input_number.test_number"
# The value might be formatted with trailing zeros
assert float(number_call.data["value"]) == 42.5
# 7b. Real homeassistant numbers pick the action from the entity domain
real_number_call = await asyncio.wait_for(
ha_real_number_futures[("number.set_value", "number.test_real_number")],
timeout=2.0,
)
assert float(real_number_call.data["value"]) == 12.5
# 7b. Real homeassistant numbers pick the action from the entity domain
real_number_call = await wait_or_fail(
ha_real_number_futures[("number.set_value", "number.test_real_number")],
2.0,
"no number.set_value call",
**ctx,
)
assert float(real_number_call.data["value"]) == 12.5
real_input_number_call = await asyncio.wait_for(
ha_real_number_futures[
("input_number.set_value", "input_number.test_real_input_number")
],
timeout=2.0,
)
assert float(real_input_number_call.data["value"]) == 7
real_input_number_call = await wait_or_fail(
ha_real_number_futures[
("input_number.set_value", "input_number.test_real_input_number")
],
2.0,
"no input_number.set_value call for the real input number",
**ctx,
)
assert float(real_input_number_call.data["value"]) == 7
# 8. HA Switch service calls
switch_on_call = await asyncio.wait_for(
service_call_futures["switch.turn_on"], timeout=2.0
)
assert switch_on_call.service == "switch.turn_on"
assert switch_on_call.data["entity_id"] == "switch.test_switch"
# 8. HA Switch service calls
switch_on_call = await wait_or_fail(
service_call_futures["switch.turn_on"], 2.0, "no switch.turn_on call", **ctx
)
assert switch_on_call.service == "switch.turn_on"
assert switch_on_call.data["entity_id"] == "switch.test_switch"
switch_off_call = await asyncio.wait_for(
service_call_futures["switch.turn_off"], timeout=2.0
)
assert switch_off_call.service == "switch.turn_off"
assert switch_off_call.data["entity_id"] == "switch.test_switch"
switch_off_call = await wait_or_fail(
service_call_futures["switch.turn_off"],
2.0,
"no switch.turn_off call",
**ctx,
)
assert switch_off_call.service == "switch.turn_off"
assert switch_off_call.data["entity_id"] == "switch.test_switch"
# 9. Action response error test (tests StringRef error message)
# The error response is sent automatically in on_service_call callback
# Wait for the error to be logged (proves StringRef -> std::string works)
error_log_line = await asyncio.wait_for(
action_error_received_future, timeout=2.0
)
test_error_message = "Test error: action not found"
assert test_error_message in error_log_line, (
f"Expected error message '{test_error_message}' not found in: {error_log_line}"
)
except TimeoutError as e:
# Show recent log lines for debugging
recent_logs = "\n".join(log_lines[-20:])
service_calls_summary = "\n".join(
f"- {call.service}" for call in ha_service_calls
)
pytest.fail(
f"Test timed out waiting for expected log pattern or service call. Error: {e}\n\n"
f"Recent log lines:\n{recent_logs}\n\n"
f"Received service calls:\n{service_calls_summary}"
)
# 9. Action response error test (tests StringRef error message)
# The error response is sent automatically in on_service_call callback
# Wait for the error to be logged (proves StringRef -> std::string works)
error_log_line = await wait_or_fail(
action_error_received_future, 2.0, "no action error logged", **ctx
)
test_error_message = "Test error: action not found"
assert test_error_message in error_log_line, (
f"Expected error message '{test_error_message}' not found in: {error_log_line}"
)
@@ -13,6 +13,7 @@ import asyncio
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -25,26 +26,9 @@ async def test_api_homeassistant_action_no_subscriber(
) -> None:
"""Undeliverable actions warn in the log; actions after subscribing arrive."""
loop = asyncio.get_running_loop()
boot_warning_future = loop.create_future()
connected_warning_future = loop.create_future()
waiter = LineWaiter()
button_action_future = loop.create_future()
def check_output(line: str) -> None:
if (
not boot_warning_future.done()
and "Home Assistant action 'test.boot_action' dropped; no client connected"
in line
):
boot_warning_future.set_result(True)
if (
not connected_warning_future.done()
and "Home Assistant action 'test.connected_action' dropped; "
"client has not subscribed to actions (yet)"
in line
):
connected_warning_future.set_result(True)
service_calls: list[HomeassistantServiceCall] = []
def on_service_call(service_call: HomeassistantServiceCall) -> None:
@@ -55,16 +39,23 @@ async def test_api_homeassistant_action_no_subscriber(
):
button_action_future.set_result(service_call)
async with run_compiled(yaml_config, line_callback=check_output):
async with run_compiled(yaml_config, line_callback=waiter.callback):
# The on_boot action fires with no client connected at all.
await asyncio.wait_for(boot_warning_future, timeout=10.0)
await waiter.wait_for(
"Home Assistant action 'test.boot_action' dropped; no client connected",
timeout=10.0,
)
async with api_client_connected() as client:
await assert_device_name(client, "test-ha-action-no-subscriber")
# on_client_connected fired at authentication, before this client
# subscribed to Home Assistant actions.
await asyncio.wait_for(connected_warning_future, timeout=5.0)
await waiter.wait_for(
"Home Assistant action 'test.connected_action' dropped; "
"client has not subscribed to actions (yet)",
timeout=5.0,
)
# After subscribing, actions must be delivered normally (and the
# dropped ones must not suddenly show up).
@@ -23,6 +23,7 @@ import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ENTITY = "light.kitchen"
SUBSCRIPTIONS = {
@@ -138,10 +139,12 @@ async def light_entity(
client.subscribe_service_calls(calls.put_nowait)
client.subscribe_states(on_state)
client.subscribe_home_assistant_states(on_state_sub)
try:
await asyncio.wait_for(all_subscribed, timeout=5.0)
except TimeoutError:
pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}")
await wait_or_fail(
all_subscribed,
5.0,
"never subscribed",
missing=lambda: SUBSCRIPTIONS - subscribed,
)
ha = FakeHomeAssistant(client, key, services, waiter, calls, states)
ha.report("supported_color_modes", modes)
ha.report("min_color_temp_kelvin", "2000")
@@ -18,6 +18,7 @@ import asyncio
from aioesphomeapi import api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -71,15 +72,10 @@ async def test_api_list_entities_backpressure(
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
pin_applied = asyncio.Event()
def _on_log_line(line: str) -> None:
if SNDBUF_PIN_LOG in line:
pin_applied.set()
async with run_compiled(config, line_callback=_on_log_line):
waiter = LineWaiter()
async with run_compiled(config, line_callback=waiter.callback):
# Fails loudly if the pin never applied
await asyncio.wait_for(pin_applied.wait(), 10)
await waiter.wait_for(SNDBUF_PIN_LOG, timeout=10)
async with RawApiClient(
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
@@ -9,6 +9,7 @@ import pytest
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -128,15 +129,13 @@ async def test_api_message_size_batching(
client.subscribe_states(on_state)
# Wait for states with timeout
try:
await asyncio.wait_for(states_future, timeout=5.0)
except TimeoutError:
missing_keys = expected_keys - received_keys
pytest.fail(
f"Did not receive states from all entities within 5 seconds. "
f"Missing keys: {missing_keys}, "
f"Received {len(received_keys)} of {len(expected_keys)} expected states"
)
await wait_or_fail(
states_future,
5.0,
"Did not receive states from all entities",
missing_keys=lambda: expected_keys - received_keys,
received=lambda: f"{len(received_keys)} of {len(expected_keys)}",
)
# Verify we received states from all entities
assert expected_keys.issubset(received_keys)
+7 -15
View File
@@ -1,10 +1,8 @@
"""Test API server reboot timeout functionality."""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .types import RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -15,21 +13,15 @@ async def test_api_reboot_timeout(
run_compiled: RunCompiledFunction,
) -> None:
"""Test that the device reboots when no API clients connect within the timeout."""
loop = asyncio.get_running_loop()
reboot_future = loop.create_future()
reboot_pattern = re.compile(r"No clients; rebooting")
def check_output(line: str) -> None:
"""Check output for reboot message."""
if not reboot_future.done() and reboot_pattern.search(line):
reboot_future.set_result(True)
waiter = LineWaiter()
# Run the device without connecting any API client
async with run_compiled(yaml_config, line_callback=check_output):
# Wait for reboot with timeout
# (0.5s reboot timeout + some margin for processing)
async with run_compiled(yaml_config, line_callback=waiter.callback):
# 0.5s reboot timeout + some margin for processing
await wait_or_fail(
reboot_future, 2.0, "Device did not reboot within expected timeout"
waiter.wait_for("No clients; rebooting"),
2.0,
"Device did not reboot within expected timeout",
)
# Test passes if we get here - reboot was detected
+12 -36
View File
@@ -2,11 +2,9 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -19,34 +17,11 @@ async def test_api_string_lambda(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test Home Assistant action lambdas that return different types are sent as strings."""
loop = asyncio.get_running_loop()
# Track log messages for all four service calls
string_called_future = loop.create_future()
int_called_future = loop.create_future()
float_called_future = loop.create_future()
char_ptr_called_future = loop.create_future()
# Patterns to match in logs - confirms the lambdas compiled and executed
string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA")
int_pattern = re.compile(r"Service called with int: 42")
float_pattern = re.compile(r"Service called with float: 3\.14")
char_ptr_pattern = re.compile(r"Service called with number for char\* test: 123")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not string_called_future.done() and string_pattern.search(line):
string_called_future.set_result(True)
if not int_called_future.done() and int_pattern.search(line):
int_called_future.set_result(True)
if not float_called_future.done() and float_pattern.search(line):
float_called_future.set_result(True)
if not char_ptr_called_future.done() and char_ptr_pattern.search(line):
char_ptr_called_future.set_result(True)
waiter = LineWaiter()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
@@ -74,15 +49,16 @@ async def test_api_string_lambda(
char_ptr_service, {"input_number": 123, "input_string": "test_string"}
)
# Wait for all service log messages
# This confirms the lambdas compiled successfully and executed
# Confirms the lambdas compiled and executed
await wait_or_fail(
asyncio.gather(
string_called_future,
int_called_future,
float_called_future,
char_ptr_called_future,
waiter.wait_for_each(
"Service called with string: STRING_FROM_LAMBDA",
"Service called with int: 42",
"Service called with float: 3.14",
"Service called with number for char* test: 123",
timeout=10.0,
),
5.0,
"One or more service log messages not received - lambda may have failed to compile or execute",
"Not every lambda service logged its call",
logged=waiter.lines,
)
@@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
WARN_PATTERN = re.compile(
@@ -23,25 +23,24 @@ async def test_blocking_warning_log_time_not_charged_to_next_operation(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Synchronous warning-log delays must not be charged to the next operation."""
loop = asyncio.get_running_loop()
complete = asyncio.Event()
warnings: list[tuple[str, int, int]] = []
injected_delay_total = 0
def check_output(line: str) -> None:
nonlocal injected_delay_total
if match := WARN_PATTERN.search(line):
warnings.append((match.group(1), int(match.group(2)), int(match.group(3))))
if match := COMPLETE_PATTERN.search(line):
injected_delay_total = int(match.group(1))
loop.call_soon_threadsafe(complete.set)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
assert await client.device_info() is not None
await asyncio.wait_for(complete.wait(), timeout=10.0)
complete_line = await waiter.wait_for(
"BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=", timeout=10.0
)
assert (match := COMPLETE_PATTERN.search(complete_line)), complete_line
injected_delay_total = int(match.group(1))
warnings = [
(m.group(1), int(m.group(2)), int(m.group(3)))
for line in waiter.lines
if (m := WARN_PATTERN.search(line))
]
assert injected_delay_total == 270, (
f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms"
+18 -42
View File
@@ -7,6 +7,7 @@ import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -17,97 +18,76 @@ async def test_branch_resume_async(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A branch that ends after a delay must resume its owner exactly once."""
lines: list[str] = []
waiters: list[tuple[str, asyncio.Future[None]]] = []
waiter = LineWaiter()
def has_marker(text: str, line: str) -> bool:
# Whole markers only: "after-if" must not match "queued-after-if-1"
return re.search(rf"(?<![\w-]){re.escape(text)}(?![\w-])", line) is not None
def check_output(line: str) -> None:
lines.append(line)
for text, future in waiters:
if has_marker(text, line) and not future.done():
future.set_result(None)
def wait_for(text: str) -> asyncio.Future[None]:
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
waiters.append((text, future))
return future
def count(text: str) -> int:
return sum(has_marker(text, line) for line in lines)
return sum(has_marker(text, line) for line in waiter.lines)
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = {s.name: s for s in services}
done = wait_for("after-if")
await client.execute_service(service["run_delay_in_if"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-if", timeout=2.0)
assert count("if-branch-after-delay") == 1
assert count("if-else-should-not-run") == 0
assert count("after-if") == 1
done = wait_for("after-else")
await client.execute_service(service["run_delay_in_else"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-else", timeout=2.0)
assert count("else-branch-after-delay") == 1
assert count("if-then-should-not-run") == 0
assert count("after-else") == 1
done = wait_for("after-while")
await client.execute_service(service["run_delay_in_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-while", timeout=2.0)
assert [count(f"while-body-{i}") for i in (1, 2, 3)] == [1, 1, 1]
assert count("while-body-4") == 0
assert count("after-while") == 1
done = wait_for("stop-check-done")
await client.execute_service(service["run_stop_in_branch"], {})
await asyncio.wait_for(done, timeout=3.0)
await waiter.wait_for("stop-check-done", timeout=3.0)
assert count("stop-running=0") == 1
assert count("stopped-branch-should-not-run") == 0
assert count("stopped-after-if-should-not-run") == 0
# A while condition that stops the script on a later pass must not start the body again
done = wait_for("later-check-done")
await client.execute_service(service["run_stop_in_later_while_check"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("later-check-done", timeout=2.0)
assert count("later-check-body-1") == 1
assert count("later-check-body-2") == 0
assert count("after-later-check-should-not-run") == 0
# An empty loop body cannot resume the loop, so the while finishes at once
done = wait_for("after-empty-while")
await client.execute_service(service["run_empty_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-empty-while", timeout=2.0)
assert count("after-empty-while") == 1
done = wait_for("after-nested-while")
await client.execute_service(service["run_nested_if_last_in_while"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-nested-while", timeout=2.0)
assert [count(f"nested-if-in-while-{i}") for i in (1, 2)] == [1, 1]
assert count("nested-if-in-while-3") == 0
assert count("after-nested-while") == 1
# Stop during the second iteration's delay: that iteration and the line after the loop never run;
# a fresh run then completes
done = wait_for("stop-while-done")
await client.execute_service(service["run_stop_mid_while"], {})
await asyncio.wait_for(done, timeout=3.0)
await waiter.wait_for("stop-while-done", timeout=3.0)
assert count("stop-while-running=0") == 1
assert count("stop-while-body-1") == 2
assert [count(f"stop-while-body-{i}") for i in (2, 3, 4, 5)] == [1, 1, 1, 1]
assert count("stop-while-done") == 1
done = wait_for("queued-after-if-3")
await client.execute_service(service["run_queued_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
queued = [line for line in lines if "queued-" in line]
await waiter.wait_for("queued-after-if-3", timeout=2.0)
queued = [line for line in waiter.lines if "queued-" in line]
order = [
"queued-branch-1",
"queued-after-if-1",
@@ -118,33 +98,29 @@ async def test_branch_resume_async(
]
assert [next(o for o in order if o in line) for line in queued] == order
done = wait_for("restart-after-if-2")
await client.execute_service(service["run_restart_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("restart-after-if-2", timeout=2.0)
await asyncio.sleep(0.2)
assert count("restart-branch-1") == 0
assert count("restart-after-if-1") == 0
assert count("restart-branch-2") == 1
assert count("restart-after-if-2") == 1
done = wait_for("after-delay-last")
await client.execute_service(service["run_delay_last_in_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("after-delay-last", timeout=2.0)
assert count("delay-last-start") == 1
assert count("after-delay-last") == 1
done = wait_for("parallel-after-if-3")
await client.execute_service(service["run_parallel_branch"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("parallel-after-if-3", timeout=2.0)
await asyncio.sleep(0.2)
for run in (1, 2, 3):
assert count(f"parallel-branch-done-{run}") == 1
assert count(f"parallel-after-if-{run}") == 1
# A condition that stops its own script must not start a branch or continue the chain
done = wait_for("stop-in-conditions-done")
await client.execute_service(service["run_stop_in_conditions"], {})
await asyncio.wait_for(done, timeout=2.0)
await waiter.wait_for("stop-in-conditions-done", timeout=2.0)
assert count("if-stopped-by-condition-should-not-run") == 0
assert count("if-else-stopped-by-condition-should-not-run") == 0
assert count("after-if-stopped-by-condition-should-not-run") == 0
+16 -59
View File
@@ -2,13 +2,11 @@
from __future__ import annotations
import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -18,71 +16,30 @@ async def test_crc8_helper(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the CRC8 helper function through integration testing."""
# Track test completion with asyncio.Event
test_complete = asyncio.Event()
waiter = LineWaiter()
# Track test results
test_results = {
"dallas_maxim": False,
"sensirion": False,
"pec": False,
"parameter_equivalence": False,
"edge_cases": False,
"component_compatibility": False,
"setup_started": False,
}
def on_log_line(line):
"""Process log lines to track test progress and results."""
# Track test start
if "CRC8 Helper Function Integration Test Starting" in line:
test_results["setup_started"] = True
# Track test completion
elif "CRC8 Integration Test Complete" in line:
test_complete.set()
# Track individual test results
elif "ALL TESTS PASSED" in line:
if "Dallas/Maxim CRC8" in line:
test_results["dallas_maxim"] = True
elif "Sensirion CRC8" in line:
test_results["sensirion"] = True
elif "PEC CRC8" in line:
test_results["pec"] = True
elif "Parameter equivalence" in line:
test_results["parameter_equivalence"] = True
elif "Edge cases" in line:
test_results["edge_cases"] = True
elif "Component compatibility" in line:
test_results["component_compatibility"] = True
# Log failures for debugging
elif "TEST FAILED:" in line or "SUBTEST FAILED:" in line:
print(f"CRC8 Test Failure: {line}")
def passed(name: str) -> bool:
return any("ALL TESTS PASSED" in line and name in line for line in waiter.lines)
# Compile and run the test
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "crc8-helper-test")
# Wait for tests to complete with timeout
await wait_or_fail(
test_complete, 5.0, "CRC8 integration test timed out after 5 seconds"
)
await waiter.wait_for("CRC8 Integration Test Complete", timeout=5.0)
# Verify all tests passed
assert test_results["setup_started"], "CRC8 test setup never started"
assert test_results["dallas_maxim"], "Dallas/Maxim CRC8 test failed"
assert test_results["sensirion"], "Sensirion CRC8 test failed"
assert test_results["pec"], "PEC CRC8 test failed"
assert test_results["parameter_equivalence"], (
"Parameter equivalence test failed"
)
assert test_results["edge_cases"], "Edge cases test failed"
assert test_results["component_compatibility"], (
"Component compatibility test failed"
)
assert any(
"CRC8 Helper Function Integration Test Starting" in line
for line in waiter.lines
), "CRC8 test setup never started"
assert passed("Dallas/Maxim CRC8"), "Dallas/Maxim CRC8 test failed"
assert passed("Sensirion CRC8"), "Sensirion CRC8 test failed"
assert passed("PEC CRC8"), "PEC CRC8 test failed"
assert passed("Parameter equivalence"), "Parameter equivalence test failed"
assert passed("Edge cases"), "Edge cases test failed"
assert passed("Component compatibility"), "Component compatibility test failed"
+10 -12
View File
@@ -27,6 +27,7 @@ from aioesphomeapi import (
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Mapping of entity name to device name for all entities with device_id
ENTITY_TO_DEVICE = {
@@ -143,20 +144,17 @@ async def test_device_id_in_state(
client.subscribe_states(on_state)
# Wait for states
try:
await asyncio.wait_for(states_future, timeout=10.0)
except TimeoutError:
received_names = {e.name for e in all_entities if e.key in states}
missing_names = (
await wait_or_fail(
states_future,
10.0,
"Did not receive all entity states",
received=lambda: len(states),
missing=lambda: (
(set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES)
- received_names
- {e.name for e in all_entities if e.key in states}
- {"Doorbell"}
)
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Received {len(states)} states. "
f"Missing: {missing_names}"
)
),
)
# Verify each state has the correct device_id
verified_count = 0
+8 -7
View File
@@ -8,6 +8,7 @@ from aioesphomeapi import EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -174,10 +175,10 @@ async def test_duplicate_entities_on_different_devices(
client.subscribe_states(on_state)
# Wait for all entity states
try:
await asyncio.wait_for(states_future, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Expected {expected_count}, received {state_count}"
)
await wait_or_fail(
states_future,
10.0,
"Did not receive all entity states",
expected=expected_count,
received=lambda: state_count,
)
+21 -20
View File
@@ -10,15 +10,19 @@ to be updated to match the other.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
RESULT_PATTERN = re.compile(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)")
@pytest.mark.asyncio
async def test_fnv1_hash_object_id(
@@ -28,8 +32,7 @@ async def test_fnv1_hash_object_id(
) -> None:
"""Test that C++ fnv1_hash_object_id matches Python implementation."""
test_results: dict[str, str] = {}
all_tests_complete = asyncio.Event()
waiter = LineWaiter()
expected_tests = {
"foo",
"upper",
@@ -41,34 +44,32 @@ async def test_fnv1_hash_object_id(
"empty",
}
def on_log_line(line: str) -> None:
"""Capture log lines with test results."""
# Strip ANSI escape codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Look for our test result messages
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
if match:
test_name = match.group(1)
result = match.group(2)
test_results[test_name] = result
if set(test_results.keys()) >= expected_tests:
all_tests_complete.set()
def test_results() -> dict[str, str]:
return {
m.group(1): m.group(2)
for line in waiter.lines
if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line)))
}
async def wait_all_results() -> None:
for name in expected_tests:
await waiter.wait_for("[FNV1_OID:", f"]: {name} ")
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "fnv1-hash-object-id-test")
# Wait for all tests to complete or timeout
await wait_or_fail(
all_tests_complete, 2.0, "Tests timed out", test_results=test_results
wait_all_results(), 2.0, "Tests timed out", test_results=test_results
)
results = test_results()
# Verify all tests passed
for test_name in expected_tests:
assert test_name in test_results, f"{test_name} test not found"
assert test_results[test_name] == "PASSED", (
assert test_name in results, f"{test_name} test not found"
assert results[test_name] == "PASSED", (
f"{test_name} test failed - C++ and Python hash mismatch"
)
+29 -30
View File
@@ -2,15 +2,19 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Format: "[timestamp][level][FNV1A:line]: test_name PASSED"
RESULT_PATTERN = re.compile(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)")
@pytest.mark.asyncio
async def test_fnv1a_hash(
@@ -20,49 +24,44 @@ async def test_fnv1a_hash(
) -> None:
"""Test that FNV-1a hash functions work correctly."""
test_results = {}
all_tests_complete = asyncio.Event()
waiter = LineWaiter()
expected_tests = {"empty", "known_hello", "known_helloworld", "extend", "string"}
def on_log_line(line: str) -> None:
"""Capture log lines with test results."""
# Strip ANSI escape codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Look for our test result messages
# Format: "[timestamp][level][FNV1A:line]: test_name PASSED"
match = re.search(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
if match:
test_name = match.group(1)
result = match.group(2)
test_results[test_name] = result
if set(test_results.keys()) >= expected_tests:
all_tests_complete.set()
def test_results() -> dict[str, str]:
return {
m.group(1): m.group(2)
for line in waiter.lines
if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line)))
}
async def wait_all_results() -> None:
for name in expected_tests:
await waiter.wait_for("[FNV1A:", f"]: {name} ")
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "fnv1a-hash-test")
# Wait for all tests to complete or timeout
await wait_or_fail(
all_tests_complete, 2.0, "Tests timed out", test_results=test_results
wait_all_results(), 2.0, "Tests timed out", test_results=test_results
)
results = test_results()
# Verify all tests passed
assert "empty" in test_results, "empty string test not found"
assert test_results["empty"] == "PASSED", "empty string test failed"
assert "empty" in results, "empty string test not found"
assert results["empty"] == "PASSED", "empty string test failed"
assert "known_hello" in test_results, "known_hello test not found"
assert test_results["known_hello"] == "PASSED", "known_hello test failed"
assert "known_hello" in results, "known_hello test not found"
assert results["known_hello"] == "PASSED", "known_hello test failed"
assert "known_helloworld" in test_results, "known_helloworld test not found"
assert test_results["known_helloworld"] == "PASSED", (
"known_helloworld test failed"
)
assert "known_helloworld" in results, "known_helloworld test not found"
assert results["known_helloworld"] == "PASSED", "known_helloworld test failed"
assert "extend" in test_results, "fnv1a_hash_extend test not found"
assert test_results["extend"] == "PASSED", "fnv1a_hash_extend test failed"
assert "extend" in results, "fnv1a_hash_extend test not found"
assert results["extend"] == "PASSED", "fnv1a_hash_extend test failed"
assert "string" in test_results, "std::string test not found"
assert test_results["string"] == "PASSED", "std::string test failed"
assert "string" in results, "std::string test not found"
assert results["string"] == "PASSED", "std::string test failed"
+69 -104
View File
@@ -2,15 +2,72 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
# Any variation of digital_read, anywhere in the message
READ_PATTERN = re.compile(r"(?:uint16_)?digital_read_(?:hw|cache) pin=\d+")
# The whole read message, so a prefixed read is reported as a mismatch
READ_MESSAGE_PATTERN = re.compile(r"^(\w+) pin=(\d+)")
HW = "digital_read_hw"
CACHE = "digital_read_cache"
UINT16_HW = "uint16_digital_read_hw"
UINT16_CACHE = "uint16_digital_read_cache"
EXPECTED_READS: list[tuple[str, int]] = [
(HW, 0),
*[(CACHE, i) for i in range(8)],
(HW, 8),
*[(CACHE, i) for i in range(8, 16)],
(HW, 16),
*[(CACHE, i) for i in range(16, 24)],
(HW, 24),
*[(CACHE, i) for i in range(24, 32)],
(HW, 3),
(CACHE, 3),
(HW, 3),
(CACHE, 3),
(CACHE, 4),
(HW, 3),
(CACHE, 3),
(HW, 10),
(CACHE, 10),
# full cache reset here for testing
(HW, 15),
(CACHE, 15),
(CACHE, 14),
(HW, 14),
(CACHE, 14),
# uint16_t component tests (single bank of 16 pins)
(UINT16_HW, 0), # First pin triggers hw read
*[(UINT16_CACHE, i) for i in range(16)], # All 16 pins return via cache
# After cache reset
(UINT16_HW, 5), # First read after reset triggers hw
(UINT16_CACHE, 5),
(UINT16_CACHE, 10), # These use cache (same bank)
(UINT16_CACHE, 15),
(UINT16_CACHE, 0),
]
def _read(line: str) -> tuple[str, int] | str | None:
"""Return (name, pin) for a read log line, the message if it is malformed, else None."""
clean_line = ANSI_PATTERN.sub("", line)
msg = clean_line.split(": ", 1)[-1]
if not READ_PATTERN.search(msg):
return None
if match := READ_MESSAGE_PATTERN.search(msg):
return match.group(1), int(match.group(2))
return msg
@pytest.mark.asyncio
async def test_gpio_expander_cache(
@@ -19,114 +76,22 @@ async def test_gpio_expander_cache(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test gpio_expander::CachedGpioExpander correctly calls hardware functions."""
logs_done = asyncio.Event()
waiter = LineWaiter()
# Patterns to match in logs - match any variation of digital_read
read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)")
read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)")
# Keep specific patterns for building the expected order
digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)")
digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)")
uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)")
uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)")
# ensure logs are in the expected order
log_order = [
(digital_read_hw_pattern, 0),
[(digital_read_cache_pattern, i) for i in range(8)],
(digital_read_hw_pattern, 8),
[(digital_read_cache_pattern, i) for i in range(8, 16)],
(digital_read_hw_pattern, 16),
[(digital_read_cache_pattern, i) for i in range(16, 24)],
(digital_read_hw_pattern, 24),
[(digital_read_cache_pattern, i) for i in range(24, 32)],
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_cache_pattern, 4),
(digital_read_hw_pattern, 3),
(digital_read_cache_pattern, 3),
(digital_read_hw_pattern, 10),
(digital_read_cache_pattern, 10),
# full cache reset here for testing
(digital_read_hw_pattern, 15),
(digital_read_cache_pattern, 15),
(digital_read_cache_pattern, 14),
(digital_read_hw_pattern, 14),
(digital_read_cache_pattern, 14),
# uint16_t component tests (single bank of 16 pins)
(uint16_read_hw_pattern, 0), # First pin triggers hw read
[
(uint16_read_cache_pattern, i) for i in range(16)
], # All 16 pins return via cache
# After cache reset
(uint16_read_hw_pattern, 5), # First read after reset triggers hw
(uint16_read_cache_pattern, 5),
(uint16_read_cache_pattern, 10), # These use cache (same bank)
(uint16_read_cache_pattern, 15),
(uint16_read_cache_pattern, 0),
]
# Flatten the log order for easier processing
log_order: list[tuple[re.Pattern, int]] = [
item
for sublist in log_order
for item in (sublist if isinstance(sublist, list) else [sublist])
]
index = 0
def check_output(line: str) -> None:
"""Check log output for expected messages."""
nonlocal index
if logs_done.is_set():
return
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Extract just the log message part (after the log level)
msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line
# Check if this line contains a read operation we're tracking
if read_hw_pattern.search(msg) or read_cache_pattern.search(msg):
if index >= len(log_order):
print(f"Received unexpected log line: {msg}")
logs_done.set()
return
pattern, expected_pin = log_order[index]
match = pattern.search(msg)
if not match:
print(f"Log line did not match next expected pattern: {msg}")
print(f"Expected pattern: {pattern.pattern}")
logs_done.set()
return
pin = int(match.group(1))
if pin != expected_pin:
print(f"Unexpected pin number. Expected {expected_pin}, got {pin}")
logs_done.set()
return
index += 1
elif "DONE_UINT16" in clean_line:
# uint16 component is done, check if we've seen all expected logs
if index == len(log_order):
logs_done.set()
# Run with log monitoring
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "gpio-expander-cache")
await wait_or_fail(logs_done, 5.0, "Timeout waiting for logs to complete")
assert index == len(log_order), (
f"Expected {len(log_order)} log entries, but got {index}"
done_line = await wait_or_fail(
waiter.wait_for("DONE_UINT16"),
5.0,
"Timeout waiting for logs to complete",
)
# Only the reads logged before the uint16 component finished count
before_done = waiter.lines[: waiter.lines.index(done_line)]
reads = [read for line in before_done if (read := _read(line)) is not None]
assert reads == EXPECTED_READS
@@ -11,6 +11,7 @@ import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -45,18 +46,11 @@ async def test_host_logger_thread_safety(
3. Verifies no lines are corrupted (partially written or interleaved)
4. Verifies all expected messages were received
"""
collected_lines: list[str] = []
test_complete_event = asyncio.Event()
def line_callback(line: str) -> None:
"""Collect log lines and detect test completion."""
collected_lines.append(line)
if "RACE_TEST_COMPLETE" in line:
test_complete_event.set()
waiter = LineWaiter()
# Run the test binary and collect output
async with (
run_compiled(yaml_config, line_callback=line_callback),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify connection works
@@ -70,10 +64,10 @@ async def test_host_logger_thread_safety(
# Wait for test to complete (with timeout)
await wait_or_fail(
test_complete_event,
waiter.wait_for("RACE_TEST_COMPLETE", timeout=40.0),
30.0,
"Test did not complete within timeout",
collected_lines=collected_lines,
collected_lines=waiter.lines,
)
# Give a bit more time for any remaining buffered messages
@@ -86,7 +80,7 @@ async def test_host_logger_thread_safety(
test_completed = False
reported_total = 0
for line in collected_lines:
for line in waiter.lines:
# Check for test start
start_match = TEST_START_PATTERN.search(line)
if start_match:
@@ -22,6 +22,7 @@ import pytest
from .state_utils import require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -65,28 +66,19 @@ async def test_host_mode_many_entities(
client.subscribe_states(on_state)
# Wait for minimum states with timeout
try:
await asyncio.wait_for(minimum_states_future, timeout=10.0)
except TimeoutError:
sensor_states = [
s
for s in states.values()
if isinstance(s, SensorState) and isinstance(s.state, float)
]
date_states = [s for s in states.values() if isinstance(s, DateState)]
time_states = [s for s in states.values() if isinstance(s, TimeState)]
datetime_states = [
s for s in states.values() if isinstance(s, DateTimeState)
]
def count(kind: type) -> int:
return sum(isinstance(s, kind) for s in states.values())
pytest.fail(
f"Did not receive expected states within 10 seconds. "
f"Received: {len(sensor_states)} sensor states (expected >=50), "
f"{len(date_states)} date states (expected >=1), "
f"{len(time_states)} time states (expected >=1), "
f"{len(datetime_states)} datetime states (expected >=1). "
f"Total states: {len(states)}"
)
await wait_or_fail(
minimum_states_future,
10.0,
"Did not receive the expected states (>=50 sensor, >=1 date, time and datetime)",
sensor=lambda: count(SensorState),
date=lambda: count(DateState),
time=lambda: count(TimeState),
datetime=lambda: count(DateTimeState),
total=lambda: len(states),
)
# Verify we received a good number of entity states
assert len(states) >= 50, (
@@ -8,6 +8,7 @@ from aioesphomeapi import EntityState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -45,16 +46,13 @@ async def test_host_mode_many_entities_multiple_connections(
client2.subscribe_states(on_state2)
# Wait for both clients to receive states
try:
count1, count2 = await asyncio.gather(
asyncio.wait_for(client1_ready, timeout=10.0),
asyncio.wait_for(client2_ready, timeout=10.0),
)
except TimeoutError:
pytest.fail(
f"One or both clients did not receive enough states within 10 seconds. "
f"Client1: {len(states1)}, Client2: {len(states2)}"
)
count1, count2 = await wait_or_fail(
asyncio.gather(client1_ready, client2_ready),
10.0,
"One or both clients did not receive enough states",
client1=lambda: len(states1),
client2=lambda: len(states2),
)
# Verify both clients received states successfully
assert count1 >= 20, (
+23 -84
View File
@@ -2,15 +2,16 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
VALUE_PATTERN = re.compile(r"value=([0-9.]+)")
@pytest.mark.asyncio
@@ -20,56 +21,16 @@ async def test_host_preferences_save_load(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that preferences are correctly saved and loaded after our optimization fix."""
loop = asyncio.get_running_loop()
log_lines: list[str] = []
preferences_saved = loop.create_future()
preferences_loaded = loop.create_future()
values_match = loop.create_future()
final_load_complete = loop.create_future()
waiter = LineWaiter()
# Patterns to match preference logs
save_pattern = re.compile(r"Preference saved: key=(\w+), value=([0-9.]+)")
load_pattern = re.compile(r"Preference loaded: key=(\w+), value=([0-9.]+)")
verify_pattern = re.compile(r"Preferences verified: values match!")
final_load_success_pattern = re.compile(
r"Final load test: loaded \d+ preferences successfully"
)
saved_values: dict[str, float] = {}
loaded_values: dict[str, float] = {}
def check_output(line: str) -> None:
"""Check log output for preference operations."""
log_lines.append(line)
# Look for save operations
match = save_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
saved_values[key] = value
if len(saved_values) >= 2 and not preferences_saved.done():
preferences_saved.set_result(True)
# Look for load operations
match = load_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
loaded_values[key] = value
if len(loaded_values) >= 2 and not preferences_loaded.done():
preferences_loaded.set_result(True)
# Look for verification
if verify_pattern.search(line) and not values_match.done():
values_match.set_result(True)
# Look for final load test completion
if final_load_success_pattern.search(line) and not final_load_complete.done():
final_load_complete.set_result(True)
async def logged_value(action: str, key: str) -> float:
line = await waiter.wait_for(f"Preference {action}: key={key},", timeout=5.0)
match = VALUE_PATTERN.search(line)
assert match is not None, f"Unexpected preference line: {line}"
return float(match.group(1))
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Get entity list
@@ -94,20 +55,10 @@ async def test_host_preferences_save_load(
# Save preferences
client.button_command(save_button.key)
# Wait for save to complete
await wait_or_fail(
preferences_saved, 5.0, "Preferences not saved within timeout"
)
# Verify we saved the expected values
assert "switch" in saved_values, f"Switch preference not saved: {saved_values}"
assert "number" in saved_values, f"Number preference not saved: {saved_values}"
assert saved_values["switch"] == 1.0, (
f"Switch value incorrect: {saved_values['switch']}"
)
assert saved_values["number"] == 42.5, (
f"Number value incorrect: {saved_values['number']}"
)
saved_switch = await logged_value("saved", "switch")
saved_number = await logged_value("saved", "number")
assert saved_switch == 1.0, f"Switch value incorrect: {saved_switch}"
assert saved_number == 42.5, f"Number value incorrect: {saved_number}"
# Change the values to something else
client.switch_command(test_switch.key, False)
@@ -116,32 +67,20 @@ async def test_host_preferences_save_load(
# Load preferences (should restore the saved values)
client.button_command(load_button.key)
# Wait for load to complete
await wait_or_fail(
preferences_loaded, 5.0, "Preferences not loaded within timeout"
loaded_switch = await logged_value("loaded", "switch")
loaded_number = await logged_value("loaded", "number")
assert loaded_switch == saved_switch, (
f"Loaded switch value {loaded_switch} doesn't match saved {saved_switch}"
)
# Verify loaded values match saved values
assert "switch" in loaded_values, (
f"Switch preference not loaded: {loaded_values}"
)
assert "number" in loaded_values, (
f"Number preference not loaded: {loaded_values}"
)
assert loaded_values["switch"] == saved_values["switch"], (
f"Loaded switch value {loaded_values['switch']} doesn't match saved {saved_values['switch']}"
)
assert loaded_values["number"] == saved_values["number"], (
f"Loaded number value {loaded_values['number']} doesn't match saved {saved_values['number']}"
assert loaded_number == saved_number, (
f"Loaded number value {loaded_number} doesn't match saved {saved_number}"
)
# Verify the values were actually restored
client.button_command(verify_button.key)
# Wait for verification
await wait_or_fail(
values_match, 5.0, "Preference verification failed within timeout"
)
await waiter.wait_for("Preferences verified: values match!", timeout=5.0)
# Test that non-existent preferences don't crash (tests our fix)
# This will trigger load attempts for keys that don't exist
@@ -149,6 +88,6 @@ async def test_host_preferences_save_load(
client.button_command(load_button.key)
# Wait for the final load test to complete
await wait_or_fail(
final_load_complete, 5.0, "Final load test did not complete within timeout"
await waiter.wait_for(
"Final load test: loaded", "preferences successfully", timeout=5.0
)
+10 -13
View File
@@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -33,26 +33,23 @@ async def test_in_range_conditions(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Each in_range condition gives the same result for sensors and numbers."""
loop = asyncio.get_running_loop()
results: dict[tuple[str, str, str], int] = {}
done = loop.create_future()
def check_output(line: str) -> None:
if match := RESULT_RE.search(line):
label, entity, condition, value = match.groups()
results[(label, entity, condition)] = int(value)
elif "INRANGE done" in line and not done.done():
done.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = require_service(services, "run_cases")
await client.execute_service(service, {})
await asyncio.wait_for(done, timeout=10.0)
await waiter.wait_for("INRANGE done", timeout=10.0)
# Key: (label, entity, condition); a repeated result keeps the last value
results = {
match.groups()[:3]: int(match[4])
for line in waiter.lines
if (match := RESULT_RE.search(line))
}
expected = {
(label, entity, condition): row[index]
for label, row in EXPECTED.items()
@@ -19,10 +19,16 @@ from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .log_utils import LineWaiter
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
CB_CW_RE = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
CB_WW_RE = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
NCB_CW_RE = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
NCB_WW_RE = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
@pytest.mark.asyncio
async def test_light_constant_brightness(
@@ -31,34 +37,19 @@ async def test_light_constant_brightness(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test constant_brightness true and false behavior with gamma correction."""
# Track output values for both lights from log lines
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
waiter = LineWaiter()
latest: dict[str, float] = {
"cb_cw": 0.0,
"cb_ww": 0.0,
"ncb_cw": 0.0,
"ncb_ww": 0.0,
}
def on_log_line(line: str) -> None:
for pattern, key in [
(cb_cw_pattern, "cb_cw"),
(cb_ww_pattern, "cb_ww"),
(ncb_cw_pattern, "ncb_cw"),
(ncb_ww_pattern, "ncb_ww"),
]:
match = pattern.search(line)
if match:
latest[key] = float(match.group(1))
def latest(pattern: re.Pattern[str]) -> float:
"""Return the last output value logged so far, or 0.0 if none."""
for line in reversed(waiter.lines):
if match := pattern.search(line):
return float(match[1])
return 0.0
loop = asyncio.get_running_loop()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
@@ -118,7 +109,7 @@ async def test_light_constant_brightness(
await send_and_wait(
cb_light.key, color_temperature=mireds, transition_length=0
)
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
cb_totals.append((mireds, latest(CB_CW_RE), latest(CB_WW_RE)))
# All totals should be approximately equal (constant brightness)
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
@@ -152,7 +143,7 @@ async def test_light_constant_brightness(
await send_and_wait(
ncb_light.key, color_temperature=mireds, transition_length=0
)
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
ncb_totals.append((mireds, latest(NCB_CW_RE), latest(NCB_WW_RE)))
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
+5 -18
View File
@@ -3,13 +3,11 @@
Tests that on_lock and on_unlock triggers work correctly.
"""
import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -19,21 +17,10 @@ async def test_lock_automations(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test lock on_lock and on_unlock triggers."""
loop = asyncio.get_running_loop()
# Futures for log line detection
on_lock_future: asyncio.Future[bool] = loop.create_future()
on_unlock_future: asyncio.Future[bool] = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for trigger messages."""
if "TRIGGER: on_lock fired" in line and not on_lock_future.done():
on_lock_future.set_result(True)
elif "TRIGGER: on_unlock fired" in line and not on_unlock_future.done():
on_unlock_future.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Import here to avoid import errors when aioesphomeapi is not installed
@@ -46,9 +33,9 @@ async def test_lock_automations(
# Test 1: Lock - should trigger on_lock
client.lock_command(key=lock.key, command=LockCommand.LOCK)
await wait_or_fail(on_lock_future, 5.0, "on_lock trigger did not fire")
await waiter.wait_for("TRIGGER: on_lock fired", timeout=5.0)
# Test 2: Unlock - should trigger on_unlock
client.lock_command(key=lock.key, command=LockCommand.UNLOCK)
await wait_or_fail(on_unlock_future, 5.0, "on_unlock trigger did not fire")
await waiter.wait_for("TRIGGER: on_unlock fired", timeout=5.0)
@@ -31,6 +31,7 @@ import pytest
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
@@ -77,14 +78,14 @@ async def test_logger_buffered_recursion_guard(
client.button_command(button.key)
# Wait until every buffered thread message has been delivered over the API.
try:
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
except TimeoutError:
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
pytest.fail(
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
"device likely crashed or hung."
)
await wait_or_fail(
all_drained,
30.0,
f"Not all {NUM_MESSAGES} thread messages arrived; device likely crashed or hung",
received=lambda: sum(
len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages
),
)
intact: set[int] = set()
contaminated: list[str] = []
@@ -16,15 +16,17 @@ requested cadence while the component phase stays gated at loop_interval_.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)")
@pytest.mark.asyncio
async def test_loop_interval_decoupling(
@@ -33,23 +35,22 @@ async def test_loop_interval_decoupling(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Raised loop_interval_ must not clamp scheduler item cadence."""
loop = asyncio.get_running_loop()
measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line)
if match and not measurement_done.done():
measurement_done.set_result((int(match.group(1)), int(match.group(2))))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "loop-interval-decouple")
loop_delta, sched_delta = await wait_or_fail(
measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared"
line = await wait_or_fail(
waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0),
10.0,
"MEASUREMENT_DONE marker never appeared",
)
match = MEASUREMENT_RE.search(line)
assert match, line
loop_delta, sched_delta = int(match[1]), int(match[2])
# Observation window = 2s, loop_interval_ = 500ms.
# Component phase should fire ~4 times in 2s. The upper bound must be
@@ -16,15 +16,17 @@ phase stays at ~62 Hz regardless of scheduler activity.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+)")
@pytest.mark.asyncio
async def test_loop_interval_default_not_pulled_forward(
@@ -33,23 +35,22 @@ async def test_loop_interval_default_not_pulled_forward(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Fast scheduler item must not pull component phase past default ~62 Hz."""
loop = asyncio.get_running_loop()
measurement_done: asyncio.Future[int] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line)
if match and not measurement_done.done():
measurement_done.set_result(int(match.group(1)))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "loop-default-not-pulled")
loop_delta = await wait_or_fail(
measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared"
line = await wait_or_fail(
waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0),
10.0,
"MEASUREMENT_DONE marker never appeared",
)
match = MEASUREMENT_RE.search(line)
assert match, line
loop_delta = int(match[1])
# Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz →
# ~125 component-phase iterations expected.
+10 -14
View File
@@ -2,11 +2,9 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -20,24 +18,22 @@ async def test_micros_to_millis(
) -> None:
"""Test that micros_to_millis matches reference uint64 division."""
all_passed = asyncio.Event()
failures: list[str] = []
waiter = LineWaiter()
def on_log_line(line: str) -> None:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "ALL_PASSED" in clean_line:
all_passed.set()
elif "FAILED" in clean_line and "[MTM" in clean_line:
failures.append(clean_line)
def failures() -> list[str]:
return [line for line in waiter.lines if "FAILED" in line and "[MTM" in line]
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "micros-to-millis-test")
await wait_or_fail(
all_passed, 2.0, "micros_to_millis test timed out", failures=failures
waiter.wait_for("ALL_PASSED"),
2.0,
"micros_to_millis test timed out",
failures=failures,
)
assert not failures, f"micros_to_millis failures: {failures}"
assert not failures(), f"micros_to_millis failures: {failures()}"
+11 -44
View File
@@ -8,14 +8,15 @@ orchestrated entirely in YAML.
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
COUNT_PATTERN = re.compile(r"count=(\d+)")
@pytest.mark.asyncio
@@ -25,29 +26,10 @@ async def test_multi_click_trigger(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that on_multi_click triggers for single, double, and long press patterns."""
loop = asyncio.get_running_loop()
single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)")
double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)")
long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)")
single_click_future: asyncio.Future[int] = loop.create_future()
double_click_future: asyncio.Future[int] = loop.create_future()
long_press_future: asyncio.Future[int] = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for multi-click trigger messages."""
if m := single_click_pattern.search(line):
if not single_click_future.done():
single_click_future.set_result(int(m.group(1)))
elif m := double_click_pattern.search(line):
if not double_click_future.done():
double_click_future.set_result(int(m.group(1)))
elif (m := long_press_pattern.search(line)) and not long_press_future.done():
long_press_future.set_result(int(m.group(1)))
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
@@ -57,24 +39,9 @@ async def test_multi_click_trigger(
# Kick off the entire test sequence (runs in YAML with delays)
await client.execute_service(test_service, {})
# Wait for all three triggers
count = await wait_or_fail(
single_click_future,
5.0,
"Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger.",
)
assert count == 1, f"Expected single click count=1, got {count}"
count = await wait_or_fail(
double_click_future,
5.0,
"Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger.",
)
assert count == 1, f"Expected double click count=1, got {count}"
count = await wait_or_fail(
long_press_future,
5.0,
"Timeout waiting for LONG_PRESS - on_multi_click did not trigger.",
)
assert count == 1, f"Expected long press count=1, got {count}"
for label in ("SINGLE_CLICK", "DOUBLE_CLICK", "LONG_PRESS"):
line = await waiter.wait_for(f"{label} count=", timeout=5.0)
match = COUNT_PATTERN.search(line)
assert match is not None and match.group(1) == "1", (
f"Expected {label} count=1, got: {line}"
)

Some files were not shown because too many files have changed in this diff Show More