mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4242bcb084 | ||
|
|
8a88e97497 | ||
|
|
d586e20a31 | ||
|
|
e6161e64b1 | ||
|
|
431843c27e | ||
|
|
a1da3d6b90 | ||
|
|
930429061a | ||
|
|
afceb91d90 | ||
|
|
7d741361a3 | ||
|
|
9c70ffd826 |
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 *¤t, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ¶m = 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user