mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00820fae7 | ||
|
|
2503dc49e3 | ||
|
|
2cd6ea7496 | ||
|
|
4dc829e091 | ||
|
|
03a76a788f | ||
|
|
807217ead6 | ||
|
|
7eb279be69 | ||
|
|
549aa0a6ba | ||
|
|
e31de889ef | ||
|
|
6897c8a4a4 | ||
|
|
e70adbb060 | ||
|
|
5bdcf2b39d | ||
|
|
87855c5967 | ||
|
|
f7b2bb8c19 | ||
|
|
9dfa251d5b | ||
|
|
eb66aafc30 | ||
|
|
e24418d201 | ||
|
|
066e95077a | ||
|
|
62f1a9b08f | ||
|
|
4921173f6c | ||
|
|
9ce82f98cc | ||
|
|
0bdd700c2e | ||
|
|
9545322be0 | ||
|
|
15afdf4dda | ||
|
|
74bdda341b | ||
|
|
6d90969f5f | ||
|
|
b1f83bc2dd | ||
|
|
4b81c8712b | ||
|
|
660b7b11f6 | ||
|
|
d039160876 | ||
|
|
67219ac41b | ||
|
|
416762802e | ||
|
|
f0b0801c3f | ||
|
|
331ee3a278 | ||
|
|
7392ac662a |
@@ -446,8 +446,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
|
||||
@@ -8,7 +8,10 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
|
||||
void CopyLock::setup() {
|
||||
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
|
||||
|
||||
this->traits = this->source_->traits;
|
||||
traits.set_assumed_state(source_->traits.get_assumed_state());
|
||||
traits.set_requires_code(source_->traits.get_requires_code());
|
||||
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
|
||||
traits.set_supports_open(source_->traits.get_supports_open());
|
||||
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::cover {
|
||||
|
||||
class CoverTraits {
|
||||
public:
|
||||
CoverTraits() = default;
|
||||
|
||||
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->flags_.supports_position; }
|
||||
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
|
||||
bool get_supports_tilt() const { return this->flags_.supports_tilt; }
|
||||
void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; }
|
||||
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->flags_.supports_stop; }
|
||||
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
|
||||
bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; }
|
||||
void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; }
|
||||
bool get_is_assumed_state() const { return this->is_assumed_state_; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->supports_position_; }
|
||||
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
|
||||
bool get_supports_tilt() const { return this->supports_tilt_; }
|
||||
void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; }
|
||||
bool get_supports_toggle() const { return this->supports_toggle_; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->supports_stop_; }
|
||||
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
|
||||
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
|
||||
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
|
||||
|
||||
protected:
|
||||
// Bit-packed flags to save memory (1 byte instead of 6)
|
||||
struct CoverTraitFlags {
|
||||
uint8_t is_assumed_state : 1;
|
||||
uint8_t supports_position : 1;
|
||||
uint8_t supports_tilt : 1;
|
||||
uint8_t supports_toggle : 1;
|
||||
uint8_t supports_stop : 1;
|
||||
uint8_t supports_stop_tilt : 1;
|
||||
uint8_t reserved : 2;
|
||||
} flags_{};
|
||||
bool is_assumed_state_{false};
|
||||
bool supports_position_{false};
|
||||
bool supports_tilt_{false};
|
||||
bool supports_toggle_{false};
|
||||
bool supports_stop_{false};
|
||||
bool supports_stop_tilt_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::cover
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
switch (lt_get_reboot_reason()) {
|
||||
case REBOOT_REASON_SLEEP_GPIO:
|
||||
@@ -19,6 +20,7 @@ WakeupCause get_wakeup_cause() {
|
||||
return WAKEUP_CAUSE_NONE;
|
||||
}
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ struct WakeupCauseToRunDuration {
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
/// Why the device woke from deep sleep. Passed to on_wake automations.
|
||||
enum WakeupCause : uint8_t {
|
||||
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
|
||||
@@ -77,11 +79,6 @@ enum WakeupCause : uint8_t {
|
||||
/// Return why the device woke from deep sleep. Implemented per platform.
|
||||
WakeupCause get_wakeup_cause();
|
||||
|
||||
/// Return true when this boot is a wake from deep sleep.
|
||||
inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; }
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
/** Setup priority of on_wake triggers.
|
||||
*
|
||||
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
switch (esp_sleep_get_wakeup_cause()) {
|
||||
case ESP_SLEEP_WAKEUP_EXT0:
|
||||
@@ -46,6 +47,7 @@ WakeupCause get_wakeup_cause() {
|
||||
return WAKEUP_CAUSE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
|
||||
if (this->wakeup_cause_to_run_duration_.has_value()) {
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
|
||||
#include <Esp.h>
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
|
||||
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
|
||||
@@ -19,6 +22,7 @@ WakeupCause get_wakeup_cause() {
|
||||
}
|
||||
return WAKEUP_CAUSE_NONE;
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A wake from System OFF is not told apart from a reset yet.
|
||||
WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; }
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
void DeepSleepComponent::dump_config_platform_() {}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
from esphome import automation, pins
|
||||
from esphome import automation
|
||||
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"]
|
||||
@@ -21,245 +10,23 @@ 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)
|
||||
|
||||
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),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x68)),
|
||||
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
|
||||
.extend(i2c.i2c_device_schema(0x68))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await i2c.new_i2c_component(config)
|
||||
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",
|
||||
DS3231_ACTION_SCHEMA,
|
||||
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,184 +9,29 @@ 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;
|
||||
}
|
||||
|
||||
// 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->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_()) {
|
||||
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))) {
|
||||
@@ -208,280 +53,26 @@ void DS3231Component::force_temperature_conversion() {
|
||||
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
|
||||
return;
|
||||
}
|
||||
// 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));
|
||||
this->control_reg_ |= CONTROL_CONV;
|
||||
this->write_control_();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
|
||||
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
|
||||
ESP_LOGW(TAG, "Can't read control/status registers.");
|
||||
return false;
|
||||
this->control_reg_ = raw[0] & ~CONTROL_CONV;
|
||||
}
|
||||
this->control_reg_ = raw[0];
|
||||
this->status_reg_ = raw[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
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)))
|
||||
bool DS3231Component::write_control_() {
|
||||
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
|
||||
ESP_LOGW(TAG, "Can't write control register.");
|
||||
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,163 +1,32 @@
|
||||
#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_();
|
||||
/// 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_();
|
||||
bool write_control_();
|
||||
|
||||
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
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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)
|
||||
@@ -1,48 +0,0 @@
|
||||
#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
|
||||
@@ -1,22 +0,0 @@
|
||||
#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
|
||||
@@ -2213,22 +2213,6 @@ def idf_version() -> cv.Version:
|
||||
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
|
||||
|
||||
|
||||
def require_idf_version(
|
||||
minimum: cv.Version, feature: str = "This feature"
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
cv.only_on_esp32(value)
|
||||
if (version := idf_version()) < minimum:
|
||||
raise cv.Invalid(
|
||||
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_fatfs() -> None:
|
||||
"""Mark that FATFS support is required by a component.
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_NONE,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
@@ -57,6 +56,7 @@ from .effects import (
|
||||
)
|
||||
from .restore_state import (
|
||||
LEGACY_RESTORE_MODES,
|
||||
RESTORE_STATE_NONE,
|
||||
RESTORE_STATE_SCHEMA,
|
||||
_build_state_lambda,
|
||||
_initial_state_overridden_by_legacy_mode,
|
||||
|
||||
@@ -11,14 +11,6 @@ import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_COLOR_MODE, CONF_STATE
|
||||
from esphome.core import Lambda
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
RESTORE_STATE_NONE,
|
||||
restore_state_keyword,
|
||||
restore_state_schema,
|
||||
)
|
||||
from esphome.cpp_generator import call_lambda
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
@@ -26,6 +18,12 @@ from esphome.types import ConfigType
|
||||
from .automation import LIGHT_STATE_FIELDS, validate_light_state
|
||||
from .types import ColorMode, LightStateRTCState
|
||||
|
||||
RESTORE_STATE_KEEP = "KEEP"
|
||||
RESTORE_STATE_INVERT = "INVERT"
|
||||
RESTORE_STATE_INITIAL = "INITIAL"
|
||||
RESTORE_STATE_ALL = "ALL"
|
||||
RESTORE_STATE_NONE = "NONE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyRestoreMode:
|
||||
@@ -292,12 +290,10 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
except cv.Invalid:
|
||||
inner = ()
|
||||
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
|
||||
return upper
|
||||
return validator(value)
|
||||
|
||||
return validate
|
||||
@@ -312,12 +308,10 @@ def _validate_restore_state_state(value: Any) -> str | bool:
|
||||
RESTORE_STATE_INITIAL,
|
||||
*validate_light_state(SCHEMA_EXTRACT),
|
||||
)
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
|
||||
return upper
|
||||
return validate_light_state(value)
|
||||
|
||||
|
||||
@@ -333,7 +327,19 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
|
||||
RESTORE_STATE_SCHEMA = restore_state_schema(
|
||||
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
|
||||
)
|
||||
@schema_extractor("schema")
|
||||
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
|
||||
"""
|
||||
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
|
||||
"""
|
||||
if value == SCHEMA_EXTRACT:
|
||||
# The `all`/`none` string shorthands have no representation in the extracted
|
||||
# docs schema; only the per-field mapping form is walked here.
|
||||
return _RESTORE_STATE_FIELDS_SCHEMA
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper == RESTORE_STATE_ALL:
|
||||
value = {}
|
||||
elif upper == RESTORE_STATE_NONE:
|
||||
return RESTORE_STATE_NONE
|
||||
return _RESTORE_STATE_FIELDS_SCHEMA(value)
|
||||
|
||||
@@ -35,12 +35,12 @@ class LockTraits {
|
||||
public:
|
||||
LockTraits() = default;
|
||||
|
||||
bool get_supports_open() const { return this->flags_.supports_open; }
|
||||
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
|
||||
bool get_requires_code() const { return this->flags_.requires_code; }
|
||||
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
|
||||
bool get_assumed_state() const { return this->flags_.assumed_state; }
|
||||
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
|
||||
bool get_supports_open() const { return this->supports_open_; }
|
||||
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
|
||||
bool get_requires_code() const { return this->requires_code_; }
|
||||
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
|
||||
bool get_assumed_state() const { return this->assumed_state_; }
|
||||
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
|
||||
|
||||
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
|
||||
void set_supported_states(std::initializer_list<LockState> states) {
|
||||
@@ -54,13 +54,9 @@ class LockTraits {
|
||||
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
|
||||
|
||||
protected:
|
||||
// Bit-packed flags to save memory (1 byte instead of 3)
|
||||
struct LockTraitFlags {
|
||||
uint8_t supports_open : 1;
|
||||
uint8_t requires_code : 1;
|
||||
uint8_t assumed_state : 1;
|
||||
uint8_t reserved : 5;
|
||||
} flags_{};
|
||||
bool supports_open_{false};
|
||||
bool requires_code_{false};
|
||||
bool assumed_state_{false};
|
||||
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
|
||||
};
|
||||
|
||||
@@ -180,8 +176,8 @@ class Lock : public EntityBase {
|
||||
*/
|
||||
virtual void control(const LockCall &call) = 0;
|
||||
|
||||
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
|
||||
LazyCallbackManager<void(LockState)> state_callback_{};
|
||||
Deduplicator<LockState> publish_dedup_;
|
||||
ESPPreferenceObject rtc_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "download_buffer.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::online_image {
|
||||
@@ -34,6 +35,46 @@ size_t DownloadBuffer::read(size_t len) {
|
||||
return this->unread_;
|
||||
}
|
||||
|
||||
void DownloadSource::compact() {
|
||||
if (this->keep_read_ || this->pos_ == this->start_)
|
||||
return;
|
||||
this->buffer_.read(this->pos_ - this->start_);
|
||||
this->start_ = this->pos_;
|
||||
}
|
||||
|
||||
int32_t DownloadSource::read(uint8_t *buf, uint32_t len) {
|
||||
const size_t offset = this->pos_ - this->start_;
|
||||
const size_t n = std::min<size_t>(len, this->buffer_.unread() - offset);
|
||||
memcpy(buf, this->buffer_.data(offset), n);
|
||||
this->pos_ += n;
|
||||
return static_cast<int32_t>(n);
|
||||
}
|
||||
|
||||
bool DownloadSource::seek(uint32_t position) {
|
||||
// Only what is still in the buffer can be read again; a decoder never skips ahead of the download
|
||||
if (position < this->start_ || position > this->start_ + this->buffer_.unread())
|
||||
return false;
|
||||
this->pos_ = position;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DownloadBuffer::grow(size_t size) {
|
||||
if (this->size_ >= size)
|
||||
return true;
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
uint8_t *buffer = allocator.allocate(size);
|
||||
if (buffer == nullptr) {
|
||||
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", size,
|
||||
allocator.get_max_free_block_size());
|
||||
return false;
|
||||
}
|
||||
memcpy(buffer, this->buffer_, this->unread_);
|
||||
allocator.deallocate(this->buffer_, this->size_);
|
||||
this->buffer_ = buffer;
|
||||
this->size_ = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t DownloadBuffer::resize(size_t size) {
|
||||
if (this->size_ >= size) {
|
||||
// Avoid useless reallocations; if the buffer is big enough, don't reallocate.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -35,6 +36,8 @@ class DownloadBuffer {
|
||||
|
||||
void reset() { this->unread_ = 0; }
|
||||
size_t resize(size_t size);
|
||||
/// Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
|
||||
bool grow(size_t size);
|
||||
|
||||
protected:
|
||||
uint8_t *buffer_;
|
||||
@@ -43,4 +46,35 @@ class DownloadBuffer {
|
||||
size_t unread_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Lets a decoder read a download as it arrives, from the download buffer.
|
||||
*
|
||||
* The decoder reads at its own position in the buffer. Bytes it has read stay there until compact() drops
|
||||
* them, and compact() keeps everything while set_keep_read(true) is in effect, so the decoder can seek back
|
||||
* while it reads the headers.
|
||||
*/
|
||||
class DownloadSource : public runtime_image::DecodeSource {
|
||||
public:
|
||||
explicit DownloadSource(DownloadBuffer &buffer) : buffer_(buffer) {}
|
||||
|
||||
/// Start again at the beginning of a new download, keeping what is read
|
||||
void reset() {
|
||||
this->start_ = 0;
|
||||
this->pos_ = 0;
|
||||
this->keep_read_ = true;
|
||||
}
|
||||
void set_keep_read(bool keep) { this->keep_read_ = keep; }
|
||||
/// Drop the bytes the decoder has read, to make room for more of the download
|
||||
void compact();
|
||||
|
||||
int32_t read(uint8_t *buf, uint32_t len) override;
|
||||
bool seek(uint32_t position) override;
|
||||
|
||||
protected:
|
||||
DownloadBuffer &buffer_;
|
||||
size_t start_{0}; // Position in the download of the first byte in the buffer
|
||||
size_t pos_{0}; // Position in the download the decoder reads next
|
||||
bool keep_read_{true};
|
||||
};
|
||||
|
||||
} // namespace esphome::online_image
|
||||
|
||||
@@ -22,6 +22,8 @@ DEPENDENCIES = ["http_request"]
|
||||
CODEOWNERS = ["@guillempages", "@clydebarrow"]
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
|
||||
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
|
||||
CONF_UPDATE = "update"
|
||||
|
||||
online_image_ns = cg.esphome_ns.namespace("online_image")
|
||||
@@ -43,7 +45,9 @@ ONLINE_IMAGE_SCHEMA = (
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
@@ -96,9 +100,17 @@ automation.register_apply_action(
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_PROGRESS,
|
||||
"add_on_progress_callback",
|
||||
[(float, "progress"), (cg.size_t, "bytes_read")],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "online_image");
|
||||
@@ -15,6 +16,11 @@ static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
|
||||
|
||||
namespace esphome::online_image {
|
||||
|
||||
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
|
||||
static constexpr uint32_t PASS_BUDGET_MS = 20;
|
||||
// Shortest time between two on_download_progress calls
|
||||
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
|
||||
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
|
||||
uint32_t buffer_size, bool is_big_endian)
|
||||
@@ -140,8 +146,12 @@ void OnlineImage::update() {
|
||||
return;
|
||||
}
|
||||
|
||||
// JPEG requires the complete image in the download buffer before decoding
|
||||
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
|
||||
// A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
|
||||
// Otherwise the decoder needs the complete image in the buffer.
|
||||
this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
|
||||
this->stream_started_ = false;
|
||||
this->source_.reset();
|
||||
if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
|
||||
this->download_buffer_.resize(total_size) == 0) {
|
||||
// resize() logged the failed allocation; the next update() allocates the configured buffer again
|
||||
this->end_connection_();
|
||||
@@ -152,6 +162,8 @@ void OnlineImage::update() {
|
||||
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
|
||||
this->start_time_ = millis();
|
||||
this->last_data_time_ = this->start_time_;
|
||||
this->download_started_callback_.call();
|
||||
this->report_progress_(true);
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
@@ -169,6 +181,11 @@ void OnlineImage::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->streaming_) {
|
||||
this->loop_stream_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if download is complete — use decoder's format-specific completion check
|
||||
// to handle both known content-length and chunked transfer encoding
|
||||
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
|
||||
@@ -179,15 +196,22 @@ void OnlineImage::loop() {
|
||||
}
|
||||
|
||||
// Download and decode more data
|
||||
size_t available = this->download_buffer_.free_capacity();
|
||||
if (available > 0) {
|
||||
// Download in chunks to avoid blocking
|
||||
available = std::min(available, this->download_buffer_initial_size_);
|
||||
auto len = this->downloader_->read(this->download_buffer_.append(), available);
|
||||
|
||||
if (len > 0) {
|
||||
this->last_data_time_ = App.get_loop_component_start_time();
|
||||
if (this->download_buffer_.free_capacity() > 0) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
size_t received = 0;
|
||||
int len;
|
||||
do {
|
||||
len = this->downloader_->read(this->download_buffer_.append(),
|
||||
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
|
||||
if (len <= 0)
|
||||
break;
|
||||
this->download_buffer_.write(len);
|
||||
received += len;
|
||||
} while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
|
||||
|
||||
if (received > 0) {
|
||||
this->last_data_time_ = now;
|
||||
this->report_progress_(false);
|
||||
|
||||
// Feed data to decoder
|
||||
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
|
||||
@@ -255,6 +279,114 @@ void OnlineImage::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void OnlineImage::loop_stream_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const size_t total = this->downloader_->content_length;
|
||||
if (this->stream_started_)
|
||||
this->source_.compact();
|
||||
|
||||
if (this->downloader_->get_bytes_read() < total) {
|
||||
if (this->download_buffer_.free_capacity() == 0) {
|
||||
// compact() already ran, so a full buffer means the decoder read nothing in the last pass
|
||||
if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
|
||||
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
|
||||
this->download_buffer_.size());
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
int len;
|
||||
do {
|
||||
len = this->downloader_->read(this->download_buffer_.append(),
|
||||
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
|
||||
if (len <= 0)
|
||||
break;
|
||||
this->last_data_time_ = now;
|
||||
this->download_buffer_.write(len);
|
||||
this->report_progress_(false);
|
||||
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
|
||||
millis() - now < PASS_BUDGET_MS);
|
||||
if (len < 0) {
|
||||
ESP_LOGE(TAG, "Error downloading image: %d", len);
|
||||
this->fail_download_();
|
||||
return;
|
||||
} else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
|
||||
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const bool arrived = this->downloader_->get_bytes_read() >= total;
|
||||
|
||||
if (!this->stream_started_) {
|
||||
// The decoder reads the headers when it opens the image, so wait until they are in the buffer
|
||||
if (!arrived && this->download_buffer_.free_capacity() != 0)
|
||||
return;
|
||||
const int err = this->begin_source(&this->source_);
|
||||
if (err != 0) {
|
||||
// Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
|
||||
// still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
|
||||
if (!arrived && (err == runtime_image::DECODE_ERROR_INVALID_TYPE || err == runtime_image::DECODE_ERROR_READ)) {
|
||||
ESP_LOGW(TAG,
|
||||
"The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
|
||||
"before it is decoded; increase buffer_size to decode it as it downloads",
|
||||
this->download_buffer_.size());
|
||||
// grow() logs a failed allocation
|
||||
if (this->download_buffer_.grow(total)) {
|
||||
this->streaming_ = false;
|
||||
} else {
|
||||
this->fail_download_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
this->source_.set_keep_read(false);
|
||||
this->stream_started_ = true;
|
||||
// Opening the image and allocating its buffer is enough for one pass
|
||||
return;
|
||||
}
|
||||
|
||||
do {
|
||||
const int result = this->decode_step();
|
||||
if (result == runtime_image::DECODE_STEP_MORE)
|
||||
continue;
|
||||
if (result == runtime_image::DECODE_STEP_DONE) {
|
||||
this->finish_download_();
|
||||
} else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
|
||||
if (!arrived)
|
||||
return;
|
||||
// JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
|
||||
ESP_LOGE(TAG, "Download ended before the image was complete");
|
||||
this->fail_download_();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
|
||||
this->fail_download_();
|
||||
}
|
||||
return;
|
||||
} while (millis() - now < PASS_BUDGET_MS);
|
||||
}
|
||||
|
||||
void OnlineImage::report_progress_(bool force) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const size_t bytes = this->downloader_->get_bytes_read();
|
||||
const size_t total = this->downloader_->content_length;
|
||||
// The last bytes are always reported, so a progress bar reaches 100%
|
||||
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
|
||||
return;
|
||||
this->last_progress_time_ = now;
|
||||
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
|
||||
this->download_progress_callback_.call(progress, bytes);
|
||||
}
|
||||
|
||||
void OnlineImage::fail_download_() {
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
}
|
||||
|
||||
void OnlineImage::finish_download_() {
|
||||
// Must be read before end_decode() ends the decoding session
|
||||
bool decoder_finished = this->is_decode_finished();
|
||||
@@ -288,6 +420,8 @@ void OnlineImage::end_connection_() {
|
||||
this->downloader_ = nullptr;
|
||||
}
|
||||
this->download_buffer_.reset();
|
||||
this->streaming_ = false;
|
||||
this->stream_started_ = false;
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ using t_http_codes = enum {
|
||||
HTTP_CODE_NOT_FOUND = 404,
|
||||
};
|
||||
|
||||
/// Largest download read(). A read() blocks until it has every byte it asked for, so this bounds how long one loop
|
||||
/// pass waits on a slow link.
|
||||
static constexpr size_t READ_CHUNK = 1460;
|
||||
|
||||
/**
|
||||
* @brief Download an image from a given URL, and decode it using the specified decoder.
|
||||
* The image will then be stored in a buffer, so that it can be re-displayed without the
|
||||
@@ -70,9 +74,17 @@ class OnlineImage final : public PollingComponent,
|
||||
*/
|
||||
void release();
|
||||
|
||||
/// Called when the server has accepted the request and the image starts to download
|
||||
template<typename F> void add_on_download_started_callback(F &&callback) {
|
||||
this->download_started_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_finished_callback(F &&callback) {
|
||||
this->download_finished_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
|
||||
template<typename F> void add_on_progress_callback(F &&callback) {
|
||||
this->download_progress_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_error_callback(F &&callback) {
|
||||
this->download_error_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -81,12 +93,20 @@ class OnlineImage final : public PollingComponent,
|
||||
bool validate_url_(const std::string &url);
|
||||
void end_connection_();
|
||||
void finish_download_();
|
||||
/// loop() for a JPEG that is decoded as it downloads
|
||||
void loop_stream_();
|
||||
void fail_download_();
|
||||
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
|
||||
void report_progress_(bool force);
|
||||
|
||||
CallbackManager<void(bool)> download_finished_callback_{};
|
||||
CallbackManager<void()> download_error_callback_{};
|
||||
LazyCallbackManager<void()> download_started_callback_{};
|
||||
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
|
||||
|
||||
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
|
||||
DownloadBuffer download_buffer_;
|
||||
DownloadSource source_{download_buffer_};
|
||||
/**
|
||||
* This is the *initial* size of the download buffer, not the current size.
|
||||
* The download buffer can be resized at runtime; the download_buffer_initial_size_
|
||||
@@ -109,6 +129,9 @@ class OnlineImage final : public PollingComponent,
|
||||
|
||||
uint32_t start_time_{0};
|
||||
uint32_t last_data_time_{0};
|
||||
uint32_t last_progress_time_{0};
|
||||
bool streaming_{false}; // The decoder reads the download itself, as it arrives
|
||||
bool stream_started_{false}; // The decoder has read the headers
|
||||
};
|
||||
|
||||
} // namespace esphome::online_image
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "runtime_image.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
@@ -11,19 +10,48 @@ ESPHOME_LOG_TAG(TAG, "image_decoder");
|
||||
bool ImageDecoder::set_size(int width, int height) {
|
||||
bool success = this->image_->resize(width, height) > 0;
|
||||
this->size_valid_ = success;
|
||||
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
|
||||
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
|
||||
this->draw_width_ = width;
|
||||
this->draw_height_ = height;
|
||||
return success;
|
||||
}
|
||||
|
||||
int ImageDecoder::scaled_x_(int x) const {
|
||||
if (this->draw_width_ <= 0)
|
||||
return 0;
|
||||
const uint32_t buffer_width = this->image_->get_buffer_width();
|
||||
return static_cast<int>((static_cast<uint32_t>(x) * buffer_width + this->draw_width_ - 1) / this->draw_width_);
|
||||
}
|
||||
|
||||
int ImageDecoder::scaled_y_(int y) const {
|
||||
if (this->draw_height_ <= 0)
|
||||
return 0;
|
||||
const uint32_t buffer_height = this->image_->get_buffer_height();
|
||||
return static_cast<int>((static_cast<uint32_t>(y) * buffer_height + this->draw_height_ - 1) / this->draw_height_);
|
||||
}
|
||||
|
||||
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
|
||||
if (!this->size_valid_) {
|
||||
return;
|
||||
}
|
||||
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
|
||||
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
|
||||
for (int i = x * this->x_scale_; i < width; i++) {
|
||||
for (int j = y * this->y_scale_; j < height; j++) {
|
||||
// A source pixel paints the target pixels from ceil(start * scale) up to ceil(end * scale). The spans of neighbouring
|
||||
// pixels meet without overlapping, so when an image is shrunk each target pixel is painted once, by the first
|
||||
// source pixel that reaches it, rather than by every pixel that lands on it. Integer maths: the S3 has no double FPU.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
int x1 = x;
|
||||
int y1 = y;
|
||||
int x2 = x + w;
|
||||
int y2 = y + h;
|
||||
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
|
||||
x1 = this->scaled_x_(x);
|
||||
y1 = this->scaled_y_(y);
|
||||
x2 = this->scaled_x_(x + w);
|
||||
y2 = this->scaled_y_(y + h);
|
||||
}
|
||||
x2 = std::min(buffer_width, x2);
|
||||
y2 = std::min(buffer_height, y2);
|
||||
for (int i = x1; i < x2; i++) {
|
||||
for (int j = y1; j < y2; j++) {
|
||||
this->image_->draw_pixel(i, j, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ class ImageDecoder {
|
||||
this->expected_size_ = 0;
|
||||
this->decoded_bytes_ = 0;
|
||||
this->size_valid_ = true;
|
||||
this->x_scale_ = 1.0;
|
||||
this->y_scale_ = 1.0;
|
||||
this->draw_width_ = 0;
|
||||
this->draw_height_ = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,8 +184,13 @@ class ImageDecoder {
|
||||
uint8_t *image_buffer_() const;
|
||||
bool image_is_big_endian_() const;
|
||||
|
||||
double x_scale_ = 1.0;
|
||||
double y_scale_ = 1.0;
|
||||
/// The first buffer column, or row, that source column x, or row y, paints: ceil(x * buffer size / draw size)
|
||||
int scaled_x_(int x) const;
|
||||
int scaled_y_(int y) const;
|
||||
|
||||
// Size of the image as the decoder draws it, which draw() spreads over the buffer
|
||||
int draw_width_ = 0;
|
||||
int draw_height_ = 0;
|
||||
RuntimeImage *image_;
|
||||
size_t expected_size_ = 0; // Expected data size (0 if unknown)
|
||||
size_t decoded_bytes_ = 0; // Bytes processed so far
|
||||
|
||||
@@ -31,8 +31,8 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
|
||||
const uint32_t target_width = this->image_->get_buffer_width();
|
||||
const uint32_t target_height = this->image_->get_buffer_height();
|
||||
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
|
||||
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
|
||||
// the generic draw() path rounds down and keeps the odd ones instead
|
||||
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6,
|
||||
// as the generic draw() path does
|
||||
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
|
||||
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
|
||||
}
|
||||
@@ -138,7 +138,7 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
const int err = this->start_();
|
||||
if (err != 0)
|
||||
return err;
|
||||
if (!this->jpeg_.decode(0, 0, 0))
|
||||
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
this->decoded_bytes_ = size;
|
||||
this->jpeg_.close();
|
||||
@@ -165,10 +165,28 @@ int JpegDecoder::start_() {
|
||||
} else {
|
||||
this->jpeg_.setPixelType(RGB8888);
|
||||
}
|
||||
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
const int width = this->jpeg_.getWidth();
|
||||
const int height = this->jpeg_.getHeight();
|
||||
if (!this->set_size(width, height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
|
||||
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
this->scale_ = 1;
|
||||
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
|
||||
height / (this->scale_ * 2) >= buffer_height) {
|
||||
this->scale_ *= 2;
|
||||
}
|
||||
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
|
||||
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
|
||||
this->draw_width_ = decoded_width;
|
||||
this->draw_height_ = decoded_height;
|
||||
if (this->scale_ != 1) {
|
||||
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
return 0;
|
||||
@@ -252,7 +270,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
this->source_ = nullptr;
|
||||
return err;
|
||||
}
|
||||
if (!this->jpeg_.decodeBegin(0, 0, 0))
|
||||
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -31,12 +31,16 @@ class JpegDecoder : public ImageDecoder {
|
||||
int HOT decode_step() override;
|
||||
|
||||
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
|
||||
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
|
||||
int get_scale() const { return this->scale_; }
|
||||
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
|
||||
|
||||
protected:
|
||||
bool prepare_rgb565_scaling_(int width, int height);
|
||||
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
|
||||
int start_();
|
||||
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
|
||||
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
|
||||
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
|
||||
int fail_();
|
||||
|
||||
@@ -49,6 +53,7 @@ class JpegDecoder : public ImageDecoder {
|
||||
FixedVector<uint16_t> y_boundaries_{};
|
||||
int source_width_{0};
|
||||
int source_height_{0};
|
||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||
bool direct_rgb565_{false};
|
||||
bool source_failed_{false};
|
||||
|
||||
@@ -116,7 +116,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
|
||||
}
|
||||
case image::IMAGE_TYPE_GRAYSCALE: {
|
||||
uint32_t pos = this->get_position_(x, y);
|
||||
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
|
||||
// 0.2125, 0.7154 and 0.0721 in 1/256ths, summing to 256 so white stays 255. Integer maths: the S3 has no
|
||||
// double FPU
|
||||
auto gray = static_cast<uint8_t>((54 * color.r + 183 * color.g + 19 * color.b) >> 8);
|
||||
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
|
||||
if (gray == 1) {
|
||||
gray = 0;
|
||||
|
||||
@@ -1,226 +1,79 @@
|
||||
#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->configure_boot_alarm_();
|
||||
this->configure_boot_timer_();
|
||||
this->read_time();
|
||||
if (this->alarm_enabled_ || this->watching_()) {
|
||||
this->start_event_polling_();
|
||||
}
|
||||
}
|
||||
void RX8111Component::setup() { 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::update() { this->read_time(); }
|
||||
|
||||
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->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
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->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
|
||||
return false;
|
||||
this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time"));
|
||||
return;
|
||||
}
|
||||
|
||||
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->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
// 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];
|
||||
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
|
||||
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;
|
||||
}
|
||||
}
|
||||
const uint8_t onehot = buf[3];
|
||||
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;
|
||||
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;
|
||||
}
|
||||
if (!valid) {
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
|
||||
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
|
||||
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("Invalid RTC time"));
|
||||
return false;
|
||||
this->status_set_warning(LOG_STR("Invalid RTC time"));
|
||||
return;
|
||||
}
|
||||
this->clear_warning_(WARN_CALENDAR);
|
||||
return true;
|
||||
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);
|
||||
}
|
||||
|
||||
void RX8111Component::write_time() {
|
||||
@@ -230,7 +83,7 @@ void RX8111Component::write_time() {
|
||||
return;
|
||||
}
|
||||
if (!this->set_stop_bit_(true)) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,233 +93,25 @@ 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->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)))
|
||||
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) {
|
||||
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 (weekday != ALARM_FIELD_UNSET) {
|
||||
extension &= ~RX8111_EXTENSION_WADA;
|
||||
} else if (day != ALARM_FIELD_UNSET) {
|
||||
extension |= RX8111_EXTENSION_WADA;
|
||||
}
|
||||
|
||||
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");
|
||||
if (!this->read_byte(RX8111_REG_CONTROL, &control))
|
||||
return false;
|
||||
if (stop) {
|
||||
control |= RX8111_CTRL_STOP;
|
||||
} else {
|
||||
control &= ~RX8111_CTRL_STOP;
|
||||
}
|
||||
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;
|
||||
return this->write_byte(RX8111_REG_CONTROL, control);
|
||||
}
|
||||
|
||||
} // namespace esphome::rx8111
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
#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;
|
||||
@@ -17,91 +12,9 @@ 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,14 +4,6 @@ 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"]
|
||||
@@ -19,186 +11,23 @@ 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
|
||||
|
||||
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,
|
||||
)
|
||||
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
|
||||
{cv.GenerateID(): cv.declare_id(RX8111Component)}
|
||||
).extend(i2c.i2c_device_schema(0x32))
|
||||
|
||||
RX8111_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(RX8111Component)}
|
||||
)
|
||||
|
||||
# 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.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),
|
||||
),
|
||||
),
|
||||
"rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()")
|
||||
)
|
||||
|
||||
|
||||
# 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),
|
||||
"rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()")
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -17,8 +17,8 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
require_idf_version,
|
||||
)
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
@@ -143,12 +143,19 @@ TYPE_CLASS = {
|
||||
}
|
||||
|
||||
|
||||
def _require_psram_dma_idf(value: Any) -> Any:
|
||||
if idf_version() < cv.Version(5, 5, 3):
|
||||
raise cv.Invalid("psram_dma requires ESP-IDF 5.5.3 or newer")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_psram_dma(value: Any) -> bool:
|
||||
value = cv.boolean(value)
|
||||
if not value:
|
||||
return value
|
||||
return cv.All(
|
||||
require_idf_version(cv.Version(5, 5, 3), "psram_dma"),
|
||||
cv.only_on_esp32,
|
||||
_require_psram_dma_idf,
|
||||
only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32C5,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome import automation, preferences
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
@@ -10,16 +8,13 @@ from esphome.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_INVERTED,
|
||||
CONF_MQTT_ID,
|
||||
CONF_ON_STATE,
|
||||
CONF_ON_TURN_OFF,
|
||||
CONF_ON_TURN_ON,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RESTORE_STATE,
|
||||
CONF_STATE,
|
||||
CONF_STORAGE,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_OUTLET,
|
||||
@@ -27,14 +22,10 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
ApplyEntity,
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
restore_state_schema,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
)
|
||||
@@ -56,8 +47,6 @@ SwitchPtr = Switch.operator("ptr")
|
||||
ApplySwitch = switch_ns.class_("ApplySwitch", Switch)
|
||||
|
||||
SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode")
|
||||
RESTORE_MODE_STORAGE_FLASH_MASK = switch_ns.RESTORE_MODE_STORAGE_FLASH_MASK
|
||||
RESTORE_MODE_STORAGE_RTC_MASK = switch_ns.RESTORE_MODE_STORAGE_RTC_MASK
|
||||
|
||||
RESTORE_MODES = {
|
||||
"RESTORE_DEFAULT_OFF": SwitchRestoreMode.SWITCH_RESTORE_DEFAULT_OFF,
|
||||
@@ -71,115 +60,6 @@ RESTORE_MODES = {
|
||||
|
||||
|
||||
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True)
|
||||
validate_restore_mode = cv.enum(RESTORE_MODES, upper=True, space="_")
|
||||
|
||||
|
||||
def _validate_restore_state_storage(config: ConfigType) -> ConfigType:
|
||||
if CONF_STORAGE in config and config[CONF_STATE] == RESTORE_STATE_INITIAL:
|
||||
raise cv.Invalid(
|
||||
"'storage' only applies when a saved state is restored (KEEP or INVERT)",
|
||||
path=[CONF_STORAGE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_restore_state_state(value: object) -> str:
|
||||
# light accepts ON/OFF here (restore, then force a state); a switch has no such mode, and
|
||||
# YAML turns ON into True, so say what to use instead of asking for quotes
|
||||
if isinstance(value, bool) or (
|
||||
isinstance(value, str) and value.upper() in ("ON", "OFF")
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"a switch can't restore and then force ON or OFF; to always start in a fixed state, "
|
||||
"use 'initial_state: ON' (or OFF) with 'restore_state: none'"
|
||||
)
|
||||
return cv.one_of(
|
||||
RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL, upper=True
|
||||
)(value)
|
||||
|
||||
|
||||
_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_STATE, default=RESTORE_STATE_KEEP
|
||||
): _validate_restore_state_state,
|
||||
# No default: when unset, the existing restore_mode storage applies (on ESP8266,
|
||||
# restore_from_flash decides), as with wifi
|
||||
cv.Optional(CONF_STORAGE): preferences.validate_storage,
|
||||
}
|
||||
)
|
||||
_RESTORE_STATE_FIELDS_SCHEMA.add_extra(_validate_restore_state_storage)
|
||||
|
||||
|
||||
# A mapping of `state` and `storage`, or the shorthands `all` (KEEP) and `none` (INITIAL)
|
||||
RESTORE_STATE_SCHEMA = restore_state_schema(
|
||||
_RESTORE_STATE_FIELDS_SCHEMA, {CONF_STATE: RESTORE_STATE_INITIAL}
|
||||
)
|
||||
|
||||
|
||||
def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||
if CONF_INITIAL_STATE in config and CONF_RESTORE_MODE in config:
|
||||
raise cv.Invalid(
|
||||
"'initial_state' can't be combined with 'restore_mode', which already sets "
|
||||
"the boot state; use 'restore_state' instead",
|
||||
path=[CONF_INITIAL_STATE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# (restore_state state, initial_state) -> restore mode
|
||||
_RESTORE_MODE_FOR_STATE = {
|
||||
(RESTORE_STATE_KEEP, False): "RESTORE_DEFAULT_OFF",
|
||||
(RESTORE_STATE_KEEP, True): "RESTORE_DEFAULT_ON",
|
||||
(RESTORE_STATE_INVERT, False): "RESTORE_INVERTED_DEFAULT_OFF",
|
||||
(RESTORE_STATE_INVERT, True): "RESTORE_INVERTED_DEFAULT_ON",
|
||||
(RESTORE_STATE_INITIAL, False): "ALWAYS_OFF",
|
||||
(RESTORE_STATE_INITIAL, True): "ALWAYS_ON",
|
||||
}
|
||||
_STATE_FOR_RESTORE_MODE = {
|
||||
mode: state for (state, _), mode in _RESTORE_MODE_FOR_STATE.items()
|
||||
}
|
||||
|
||||
|
||||
def _apply_default_restore_mode(
|
||||
default_restore_mode: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
# cv.Exclusive has no default, so apply it here when no restore option is configured.
|
||||
# With only initial_state set, a restoring default keeps restoring and initial_state
|
||||
# supplies the state used when nothing is saved.
|
||||
default = validate_restore_mode(default_restore_mode)
|
||||
state = _STATE_FOR_RESTORE_MODE.get(str(default), RESTORE_STATE_INITIAL)
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if CONF_RESTORE_MODE in config or CONF_RESTORE_STATE in config:
|
||||
return config
|
||||
if CONF_INITIAL_STATE in config:
|
||||
config[CONF_RESTORE_STATE] = {CONF_STATE: state}
|
||||
else:
|
||||
config[CONF_RESTORE_MODE] = default
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _resolve_restore_mode(config: ConfigType) -> MockObj | str:
|
||||
"""Translate `initial_state` / `restore_state` into one of the RESTORE_MODES, with the storage bit."""
|
||||
if (restore_mode := config.get(CONF_RESTORE_MODE)) is not None:
|
||||
return restore_mode
|
||||
restore_state = config.get(CONF_RESTORE_STATE, {})
|
||||
state = restore_state.get(CONF_STATE, RESTORE_STATE_INITIAL)
|
||||
mode = RESTORE_MODES[
|
||||
_RESTORE_MODE_FOR_STATE[state, config.get(CONF_INITIAL_STATE, False)]
|
||||
]
|
||||
if (storage := restore_state.get(CONF_STORAGE)) is None:
|
||||
return mode
|
||||
cg.add_define("USE_SWITCH_STORAGE")
|
||||
mask = (
|
||||
RESTORE_MODE_STORAGE_FLASH_MASK
|
||||
if preferences.is_in_flash(storage)
|
||||
else RESTORE_MODE_STORAGE_RTC_MASK
|
||||
)
|
||||
return cg.RawExpression(f"static_cast<{SwitchRestoreMode}>({mode} | {mask})")
|
||||
|
||||
|
||||
_SWITCH_SCHEMA = (
|
||||
@@ -190,9 +70,9 @@ _SWITCH_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSwitchComponent),
|
||||
cv.Optional(CONF_INVERTED): cv.boolean,
|
||||
cv.Exclusive(CONF_RESTORE_MODE, "restore"): validate_restore_mode,
|
||||
cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA,
|
||||
cv.Optional(CONF_INITIAL_STATE): cv.boolean,
|
||||
cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum(
|
||||
RESTORE_MODES, upper=True, space="_"
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
|
||||
@@ -206,7 +86,6 @@ _SWITCH_SCHEMA = (
|
||||
|
||||
_SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch"))
|
||||
_SWITCH_SCHEMA.add_extra(zigbee.validate_switch)
|
||||
_SWITCH_SCHEMA.add_extra(_validate_initial_state)
|
||||
|
||||
|
||||
def switch_schema(
|
||||
@@ -224,6 +103,13 @@ def switch_schema(
|
||||
(CONF_DEVICE_CLASS, device_class, validate_device_class),
|
||||
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
|
||||
(CONF_ICON, icon, cv.icon),
|
||||
(
|
||||
CONF_RESTORE_MODE,
|
||||
default_restore_mode,
|
||||
cv.enum(RESTORE_MODES, upper=True, space="_")
|
||||
if default_restore_mode is not cv.UNDEFINED
|
||||
else cv.UNDEFINED,
|
||||
),
|
||||
]:
|
||||
if default is not cv.UNDEFINED:
|
||||
schema[cv.Optional(key, default=default)] = validator
|
||||
@@ -233,15 +119,7 @@ def switch_schema(
|
||||
"Inverted is not supported for this platform!"
|
||||
)
|
||||
|
||||
result = _SWITCH_SCHEMA.extend(schema)
|
||||
result.add_extra(
|
||||
_apply_default_restore_mode(
|
||||
"ALWAYS_OFF"
|
||||
if default_restore_mode is cv.UNDEFINED
|
||||
else default_restore_mode
|
||||
)
|
||||
)
|
||||
return result
|
||||
return _SWITCH_SCHEMA.extend(schema)
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
@@ -281,7 +159,7 @@ async def setup_switch_core_(var, config):
|
||||
|
||||
setup_device_class(config)
|
||||
|
||||
cg.add(var.set_restore_mode(_resolve_restore_mode(config)))
|
||||
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||
await zigbee.setup_switch(var, config)
|
||||
|
||||
|
||||
|
||||
@@ -33,14 +33,7 @@ optional<bool> Switch::get_initial_state() {
|
||||
if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK))
|
||||
return {};
|
||||
|
||||
#ifdef USE_SWITCH_STORAGE
|
||||
if (restore_mode & RESTORE_MODE_STORAGE_MASK) {
|
||||
this->rtc_ = this->make_entity_preference<bool>(0, restore_mode & RESTORE_MODE_STORAGE_FLASH_MASK);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
this->rtc_ = this->make_entity_preference<bool>();
|
||||
}
|
||||
this->rtc_ = this->make_entity_preference<bool>();
|
||||
bool initial_state;
|
||||
if (!this->rtc_.load(&initial_state))
|
||||
return {};
|
||||
@@ -60,13 +53,6 @@ optional<bool> Switch::get_initial_state_with_restore_mode() {
|
||||
}
|
||||
return initial_state;
|
||||
}
|
||||
void Switch::restore() {
|
||||
if (auto initial = this->get_initial_state_with_restore_mode())
|
||||
this->control(*initial);
|
||||
}
|
||||
void Switch::restore(bool when_disabled) {
|
||||
this->control(this->get_initial_state_with_restore_mode().value_or(when_disabled));
|
||||
}
|
||||
void Switch::publish_state(bool state) {
|
||||
if (!this->publish_dedup_.next(state))
|
||||
return;
|
||||
|
||||
@@ -14,16 +14,11 @@ namespace esphome::switch_ {
|
||||
public: \
|
||||
void set_##name##_switch(switch_::Switch *s) { this->name##_switch_ = s; }
|
||||
|
||||
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled.
|
||||
// bit4: restore from flash. bit5: restore from RTC memory (neither: platform default).
|
||||
// Code generation ors the storage bit into the restore mode passed to set_restore_mode().
|
||||
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled
|
||||
constexpr int RESTORE_MODE_ON_MASK = 0x01;
|
||||
constexpr int RESTORE_MODE_PERSISTENT_MASK = 0x02;
|
||||
constexpr int RESTORE_MODE_INVERTED_MASK = 0x04;
|
||||
constexpr int RESTORE_MODE_DISABLED_MASK = 0x08;
|
||||
constexpr int RESTORE_MODE_STORAGE_FLASH_MASK = 0x10;
|
||||
constexpr int RESTORE_MODE_STORAGE_RTC_MASK = 0x20;
|
||||
constexpr int RESTORE_MODE_STORAGE_MASK = RESTORE_MODE_STORAGE_FLASH_MASK | RESTORE_MODE_STORAGE_RTC_MASK;
|
||||
|
||||
enum SwitchRestoreMode : uint8_t {
|
||||
SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK,
|
||||
@@ -115,11 +110,6 @@ class Switch : public EntityBase {
|
||||
*/
|
||||
optional<bool> get_initial_state_with_restore_mode();
|
||||
|
||||
/// Drive the switch to the state its restore mode yields, through control(), so inversion and
|
||||
/// write_state() apply; a disabled restore mode drives \p when_disabled, or nothing without one.
|
||||
void restore();
|
||||
void restore(bool when_disabled);
|
||||
|
||||
/** Return whether this switch uses an assumed state - i.e. if both the ON/OFF actions should be displayed in Home
|
||||
* Assistant because the real state is unknown.
|
||||
*
|
||||
@@ -149,9 +139,9 @@ class Switch : public EntityBase {
|
||||
LazyCallbackManager<void(bool)> state_callback_{};
|
||||
|
||||
// Small types grouped together
|
||||
Deduplicator<bool> publish_dedup_; // 3 bytes (bool has_value_ + bool value_unknown_ + bool last_value_)
|
||||
Deduplicator<bool> publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_)
|
||||
bool inverted_{false}; // 1 byte
|
||||
// Total: 4 bytes, no padding
|
||||
// Total: 3 bytes, 1 byte padding
|
||||
};
|
||||
|
||||
#define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
@@ -11,7 +9,6 @@ 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,
|
||||
@@ -20,15 +17,12 @@ 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,
|
||||
@@ -39,8 +33,6 @@ 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)
|
||||
|
||||
|
||||
@@ -67,9 +59,6 @@ _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}
|
||||
@@ -86,7 +75,6 @@ _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.
|
||||
@@ -102,10 +90,6 @@ 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,
|
||||
@@ -117,42 +101,15 @@ TIC_TAG_CONFIGS = {
|
||||
"ADPS": _CURRENT,
|
||||
"ADIR": _CURRENT,
|
||||
"PAPP": _APPARENT_POWER_VA,
|
||||
"PMAX": _APPARENT_POWER_VA,
|
||||
"PEJP": _DURATION_MIN,
|
||||
"PMAX": _POWER,
|
||||
}
|
||||
|
||||
# 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:
|
||||
@@ -160,12 +117,6 @@ 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,
|
||||
@@ -177,8 +128,8 @@ def apply_tag_config(config: ConfigType) -> ConfigType:
|
||||
config.setdefault(key, value)
|
||||
return config
|
||||
|
||||
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))
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
|
||||
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
|
||||
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
|
||||
|
||||
for key, value in preset.items():
|
||||
|
||||
@@ -2,57 +2,95 @@
|
||||
|
||||
namespace esphome::unicode {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
// 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++;
|
||||
|
||||
// check for end of string
|
||||
if (c1 == 0) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
|
||||
if (c1 < 0x80) {
|
||||
count = 0;
|
||||
// Valid ASCII byte.
|
||||
code_point = c1;
|
||||
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;
|
||||
// Optimization: No need to check for continuation bytes.
|
||||
}
|
||||
if (!read_continuation(current, count, code_point) || code_point < lowest || code_point > 0x10FFFF ||
|
||||
(code_point >= 0xD800 && code_point <= 0xDFFF)) {
|
||||
// --- 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 {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
*length = current - start;
|
||||
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
|
||||
return code_point;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
require_idf_version,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID
|
||||
@@ -202,9 +201,10 @@ def fifo_split() -> tuple[int, int, int] | None:
|
||||
def _reconcile_fifo() -> None:
|
||||
if (split := fifo_split()) is None:
|
||||
return
|
||||
require_idf_version(
|
||||
FIFO_CUSTOM_MIN_IDF, f"Sizing the USB FIFO on the {get_esp32_variant()}"
|
||||
)(None)
|
||||
if idf_version() < FIFO_CUSTOM_MIN_IDF:
|
||||
raise cv.Invalid(
|
||||
f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later"
|
||||
)
|
||||
rx, nptx, ptx = split
|
||||
cg.add_define("USE_USB_HOST_FIFO_CONFIG")
|
||||
cg.add_define("USB_HOST_FIFO_RX_LINES", rx)
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::valve {
|
||||
|
||||
class ValveTraits {
|
||||
public:
|
||||
ValveTraits() = default;
|
||||
|
||||
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->flags_.supports_position; }
|
||||
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
|
||||
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->flags_.supports_stop; }
|
||||
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
|
||||
bool get_is_assumed_state() const { return this->is_assumed_state_; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->supports_position_; }
|
||||
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
|
||||
bool get_supports_toggle() const { return this->supports_toggle_; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->supports_stop_; }
|
||||
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
|
||||
|
||||
protected:
|
||||
// Bit-packed flags to save memory (1 byte instead of 4)
|
||||
struct ValveTraitFlags {
|
||||
uint8_t is_assumed_state : 1;
|
||||
uint8_t supports_position : 1;
|
||||
uint8_t supports_toggle : 1;
|
||||
uint8_t supports_stop : 1;
|
||||
uint8_t reserved : 4;
|
||||
} flags_{};
|
||||
bool is_assumed_state_{false};
|
||||
bool supports_position_{false};
|
||||
bool supports_toggle_{false};
|
||||
bool supports_stop_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::valve
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
#include "weikai.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
namespace esphome::weikai {
|
||||
|
||||
@@ -38,34 +37,35 @@ const char *p2s(uart::UARTParityOptions parity) {
|
||||
}
|
||||
|
||||
/// @brief Display a buffer in hexadecimal format (32 hex values / line) for debug
|
||||
void print_buffer(const char *tag, const uint8_t *data, size_t length, int level) {
|
||||
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_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
|
||||
ESP_LOGVV(TAG, " %s", hex_buffer);
|
||||
}
|
||||
}
|
||||
if (length % 32) {
|
||||
// null terminate if incomplete line
|
||||
hex_buffer[3 * (length % 32) + 2] = 0;
|
||||
esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
|
||||
ESP_LOGVV(TAG, " %s", hex_buffer);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
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"};
|
||||
|
||||
const LogString *reg_to_str(int reg, bool page1) {
|
||||
// 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 LOG_STR("GPDAT");
|
||||
return "GPDAT";
|
||||
} else if (reg == WKREG_GPDIR) {
|
||||
return LOG_STR("GPDIR");
|
||||
return "GPDIR";
|
||||
} else {
|
||||
return page1 ? RegNamesPage1::get_log_str(reg & 0x0F, 0) : RegNamesPage0::get_log_str(reg & 0x0F, 0);
|
||||
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,24 @@ 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();
|
||||
@@ -542,7 +560,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(TAG, buffer.data(), buffer.size(), ESPHOME_LOG_LEVEL_INFO);
|
||||
print_buffer(buffer);
|
||||
status = false;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,6 @@ 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,7 +8,38 @@
|
||||
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
|
||||
@@ -41,11 +72,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,
|
||||
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
|
||||
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,
|
||||
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
|
||||
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -60,7 +91,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(TAG, data, length);
|
||||
print_buffer(data, length);
|
||||
#endif
|
||||
} else { // error
|
||||
this->comp_->status_set_warning();
|
||||
@@ -77,11 +108,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,
|
||||
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
|
||||
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,
|
||||
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
|
||||
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +126,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(TAG, data, length);
|
||||
print_buffer(data, length);
|
||||
#endif
|
||||
} else { // error
|
||||
this->comp_->status_set_warning();
|
||||
|
||||
@@ -35,6 +35,39 @@ 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
|
||||
|
||||
@@ -68,7 +101,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,
|
||||
LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, val);
|
||||
reg_to_str(this->register_, this->comp_->page1()), this->channel_, val);
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -83,7 +116,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(TAG, data, length);
|
||||
print_buffer(data, length);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -95,7 +128,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], LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, buf[1]);
|
||||
buf[0], reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]);
|
||||
}
|
||||
|
||||
void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) {
|
||||
@@ -110,7 +143,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(TAG, data, length);
|
||||
print_buffer(data, length);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
const,
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
request_wifi,
|
||||
require_idf_version,
|
||||
require_mbedtls_tls,
|
||||
require_mbedtls_tls_extras,
|
||||
)
|
||||
@@ -232,8 +232,8 @@ def _validate_ap_mode(value: str) -> str:
|
||||
def _validate_dns_servers(value: list) -> list:
|
||||
if any(ip.is_unspecified for ip in value):
|
||||
raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer")
|
||||
if len(value) > 1:
|
||||
require_idf_version(cv.Version(6, 0, 0), "A second DNS server")(value)
|
||||
if len(value) > 1 and CORE.is_esp32 and idf_version() < cv.Version(6, 0, 0):
|
||||
raise cv.Invalid("The access point offers a second DNS server from ESP-IDF 6.0")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -297,7 +297,6 @@
|
||||
#define USE_STATUS_LED
|
||||
#define USE_STATUS_SENSOR
|
||||
#define USE_SWITCH
|
||||
#define USE_SWITCH_STORAGE
|
||||
#define USE_TEXT
|
||||
#define USE_TEXT_SENSOR
|
||||
#define USE_TEXT_SENSOR_FILTER
|
||||
|
||||
@@ -42,7 +42,6 @@ from esphome.helpers import (
|
||||
sanitize,
|
||||
snake_case,
|
||||
)
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -765,50 +764,3 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# restore_state: keywords, shared by the entity domains that save their state
|
||||
RESTORE_STATE_KEEP = "KEEP"
|
||||
RESTORE_STATE_INVERT = "INVERT"
|
||||
RESTORE_STATE_INITIAL = "INITIAL"
|
||||
RESTORE_STATE_ALL = "ALL"
|
||||
RESTORE_STATE_NONE = "NONE"
|
||||
|
||||
|
||||
def restore_state_keyword(value: Any, keywords: tuple[str, ...]) -> str | None:
|
||||
"""The upper-cased keyword when `value` spells one of `keywords`, else None."""
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in keywords:
|
||||
return upper
|
||||
return None
|
||||
|
||||
|
||||
def restore_state_schema(fields_schema: cv.Schema, none: Any) -> Callable[[Any], Any]:
|
||||
"""The validator for a domain's `restore_state:` key.
|
||||
|
||||
A mapping is validated with `fields_schema`. The shorthand `all` is the empty mapping,
|
||||
so every field keeps its saved value, and `none` is returned as it is; a mapping goes through
|
||||
`fields_schema` like one the user wrote, so each config gets its own copy.
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def validate(value: Any) -> Any:
|
||||
if value == SCHEMA_EXTRACT:
|
||||
# The shorthands have no representation in the extracted schema
|
||||
return fields_schema
|
||||
keyword = restore_state_keyword(value, (RESTORE_STATE_ALL, RESTORE_STATE_NONE))
|
||||
if keyword == RESTORE_STATE_ALL:
|
||||
value = {}
|
||||
elif keyword == RESTORE_STATE_NONE:
|
||||
if not isinstance(none, dict):
|
||||
return none
|
||||
value = none
|
||||
elif isinstance(value, str):
|
||||
raise cv.Invalid(
|
||||
f"Unknown restore_state '{value}'; expected 'all', 'none' or a mapping, "
|
||||
"for example '{state: INVERT}'"
|
||||
)
|
||||
return fields_schema(value)
|
||||
|
||||
return validate
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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"})
|
||||
@@ -32,10 +32,8 @@ from esphome.components.esp32 import (
|
||||
_user_sdkconfig_wants_tls,
|
||||
allow_insecure_tls,
|
||||
idf_version,
|
||||
require_idf_version,
|
||||
)
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP32,
|
||||
KEY_EXCLUDE_COMPONENTS,
|
||||
KEY_IDF_VERSION,
|
||||
@@ -2444,44 +2442,3 @@ def test_allow_insecure_tls_only_switches_the_flags_on(
|
||||
"CONFIG_ESP_TLS_INSECURE": True,
|
||||
"CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_framework", "idf", "ok"),
|
||||
[
|
||||
(PlatformFramework.ESP32_IDF, cv.Version(5, 5, 3), True),
|
||||
(PlatformFramework.ESP32_IDF, cv.Version(5, 5, 2), False),
|
||||
(PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 3), True),
|
||||
(PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 2), False),
|
||||
],
|
||||
)
|
||||
def test_require_idf_version_compares_the_idf_underneath(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
idf: cv.Version,
|
||||
ok: bool,
|
||||
) -> None:
|
||||
set_core_config(
|
||||
platform_framework,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32dev",
|
||||
KEY_VARIANT: VARIANT_ESP32,
|
||||
KEY_IDF_VERSION: idf,
|
||||
},
|
||||
)
|
||||
validator = require_idf_version(cv.Version(5, 5, 3), "The thing")
|
||||
if ok:
|
||||
assert validator("x") == "x"
|
||||
else:
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="The thing requires ESP-IDF 5.5.3 or newer, got 5.5.2"
|
||||
):
|
||||
validator("x")
|
||||
|
||||
|
||||
def test_require_idf_version_rejects_other_platforms(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="only available on"):
|
||||
require_idf_version(cv.Version(5, 5, 3))("x")
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""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,12 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF}
|
||||
- {platform: template, id: plain, optimistic: true}
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}}
|
||||
- {platform: template, id: keep_flash, optimistic: true, restore_state: {state: KEEP, storage: flash}}
|
||||
- {platform: template, id: keep_short, optimistic: true, restore_state: {state: KEEP}}
|
||||
- {platform: template, id: all_short, optimistic: true, restore_state: ALL}
|
||||
- {platform: template, id: invert_on, optimistic: true, initial_state: ON, restore_state: {state: INVERT}}
|
||||
- {platform: template, id: initial_on, optimistic: true, initial_state: ON, restore_state: {state: INITIAL}}
|
||||
- {platform: template, id: none_on, optimistic: true, initial_state: ON, restore_state: NONE}
|
||||
- {platform: template, id: initial_only, optimistic: true, initial_state: ON}
|
||||
- {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF}
|
||||
- {platform: template, id: plain, optimistic: true}
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp12e
|
||||
restore_from_flash: true
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""Tests for switch `initial_state` / `restore_state` (including `storage`)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import switch
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7251,
|
||||
FAMILY_LN882H,
|
||||
FAMILY_RTL8720C,
|
||||
KEY_FAMILY,
|
||||
KEY_LIBRETINY,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
GATE = "USE_SWITCH_STORAGE"
|
||||
ESP32_RTC = "USE_ESP32_RTC_PREFERENCES"
|
||||
|
||||
|
||||
def _defines() -> set[str]:
|
||||
return {define.name for define in CORE.defines}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("switch_id", "mode"),
|
||||
[
|
||||
("keep_short", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("all_short", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("invert_on", "SWITCH_RESTORE_INVERTED_DEFAULT_ON"),
|
||||
("initial_on", "SWITCH_ALWAYS_ON"),
|
||||
("none_on", "SWITCH_ALWAYS_ON"),
|
||||
("initial_only", "SWITCH_ALWAYS_ON"),
|
||||
("legacy", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("plain", "SWITCH_ALWAYS_OFF"),
|
||||
],
|
||||
)
|
||||
def test_restore_state_maps_to_a_restore_mode(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
switch_id: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""Every initial_state / restore_state combination becomes one existing restore mode."""
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp32.yaml"))
|
||||
|
||||
assert f"{switch_id}->set_restore_mode(switch_::{mode});" in main_cpp
|
||||
|
||||
|
||||
def test_storage_is_passed_only_when_set(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp32.yaml"))
|
||||
|
||||
assert (
|
||||
"keep_rtc->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));"
|
||||
) in main_cpp
|
||||
assert (
|
||||
"keep_flash->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_OFF | switch_::RESTORE_MODE_STORAGE_FLASH_MASK));"
|
||||
) in main_cpp
|
||||
assert (
|
||||
"keep_short->set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_OFF);" in main_cpp
|
||||
)
|
||||
assert GATE in _defines()
|
||||
assert ESP32_RTC in _defines()
|
||||
|
||||
|
||||
def test_nothing_new_emitted_without_restore_state(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Configs using only restore_mode generate exactly what they did before."""
|
||||
main_cpp = generate_main(component_config_path("restore_state_absent_esp32.yaml"))
|
||||
|
||||
assert "RESTORE_MODE_STORAGE_" not in main_cpp
|
||||
assert GATE not in _defines()
|
||||
assert ESP32_RTC not in _defines()
|
||||
|
||||
|
||||
def test_rtc_applies_per_switch_on_esp8266_with_restore_from_flash(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp8266.yaml"))
|
||||
|
||||
assert (
|
||||
"keep_rtc->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));"
|
||||
) in main_cpp
|
||||
assert ESP32_RTC not in _defines()
|
||||
|
||||
|
||||
def _validate(config: dict, *, default_restore_mode: str = cv.UNDEFINED) -> dict:
|
||||
schema = switch.switch_schema(
|
||||
switch.Switch, default_restore_mode=default_restore_mode
|
||||
)
|
||||
return schema({"id": "test_switch", **config})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def esp8266(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_restore_mode_and_restore_state_are_exclusive() -> None:
|
||||
with pytest.raises(cv.Invalid, match="restore"):
|
||||
_validate({"restore_mode": "RESTORE_DEFAULT_OFF", "restore_state": "all"})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_initial_state_rejected_with_restore_mode() -> None:
|
||||
"""restore_mode already decides the boot state, so initial_state would be ignored."""
|
||||
with pytest.raises(cv.Invalid, match="initial_state"):
|
||||
_validate({"restore_mode": "ALWAYS_OFF", "initial_state": True})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_storage_rejected_when_nothing_is_restored() -> None:
|
||||
with pytest.raises(cv.Invalid, match="only applies"):
|
||||
_validate({"restore_state": {"state": "INITIAL", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize("value", ["RESTORE", "ON", "OFF", "KEEP", "INVERT", "INITIAL"])
|
||||
def test_unsupported_restore_state_values_rejected(value: object) -> None:
|
||||
# Only light's shorthands (all, none) are accepted as a string
|
||||
with pytest.raises(cv.Invalid, match="expected 'all', 'none' or a mapping"):
|
||||
_validate({"restore_state": value})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize("state", [True, False, "ON", "off"])
|
||||
def test_restore_state_on_off_explains_what_to_use(state: object) -> None:
|
||||
# Valid on light, so people will try it; YAML turns ON into True
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="initial_state: ON' \\(or OFF\\) with 'restore_state: none'"
|
||||
):
|
||||
_validate({"restore_state": {"state": state}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "family"),
|
||||
[
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7251),
|
||||
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C),
|
||||
(PlatformFramework.LN882X_ARDUINO, FAMILY_LN882H),
|
||||
(PlatformFramework.RP2040_ARDUINO, None),
|
||||
(PlatformFramework.NRF52_ZEPHYR, None),
|
||||
(PlatformFramework.HOST_NATIVE, None),
|
||||
],
|
||||
)
|
||||
def test_rtc_rejected_without_an_rtc_backend(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform: PlatformFramework,
|
||||
family: str | None,
|
||||
) -> None:
|
||||
"""These preferences backends ignore in_flash, so the state would silently stay in flash."""
|
||||
set_core_config(platform)
|
||||
if family is not None:
|
||||
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family}
|
||||
with pytest.raises(cv.Invalid, match="'rtc' storage is not supported"):
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
def test_rtc_accepted_on_a_beken_chip_with_the_emulation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: FAMILY_BK7231T}
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_rtc_accepted_on_esp8266() -> None:
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_platform_default_restore_mode_applies_only_when_nothing_is_set() -> None:
|
||||
"""Platforms such as haier default to DISABLED; restore_state must still be usable there."""
|
||||
assert _validate({}, default_restore_mode="DISABLED")["restore_mode"] == "DISABLED"
|
||||
configured = _validate({"restore_state": "all"}, default_restore_mode="DISABLED")
|
||||
assert "restore_mode" not in configured
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize(
|
||||
("default_restore_mode", "initial_state", "expected"),
|
||||
[
|
||||
# A restoring default keeps restoring; initial_state only replaces its fallback
|
||||
("RESTORE_DEFAULT_ON", False, "RESTORE_DEFAULT_OFF"),
|
||||
("RESTORE_DEFAULT_OFF", True, "RESTORE_DEFAULT_ON"),
|
||||
("RESTORE_INVERTED_DEFAULT_OFF", True, "RESTORE_INVERTED_DEFAULT_ON"),
|
||||
# A default that restores nothing starts in initial_state
|
||||
("ALWAYS_OFF", True, "ALWAYS_ON"),
|
||||
("DISABLED", True, "ALWAYS_ON"),
|
||||
],
|
||||
)
|
||||
def test_initial_state_alone_keeps_a_restoring_platform_default(
|
||||
default_restore_mode: str, initial_state: bool, expected: str
|
||||
) -> None:
|
||||
config = _validate(
|
||||
{"initial_state": initial_state}, default_restore_mode=default_restore_mode
|
||||
)
|
||||
assert "restore_mode" not in config
|
||||
assert "storage" not in config.get("restore_state", {})
|
||||
assert switch._resolve_restore_mode(config) is switch.RESTORE_MODES[expected]
|
||||
@@ -1,15 +1,9 @@
|
||||
"""Tests for teleinfo sensor tag presets."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.teleinfo import CONF_TAG_NAME
|
||||
from esphome.components.teleinfo.sensor import (
|
||||
TIC_TAG_CONFIGS,
|
||||
TIC_TEXT_TAGS,
|
||||
apply_tag_config,
|
||||
)
|
||||
from esphome.components.teleinfo.sensor import apply_tag_config
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ICON,
|
||||
@@ -17,7 +11,6 @@ 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,
|
||||
@@ -29,7 +22,6 @@ from esphome.const import (
|
||||
UNIT_KILOVOLT_AMPS,
|
||||
UNIT_KILOWATT_HOURS,
|
||||
UNIT_MILLIAMP,
|
||||
UNIT_MINUTE,
|
||||
UNIT_VOLT,
|
||||
UNIT_VOLT_AMPS,
|
||||
UNIT_VOLT_AMPS_REACTIVE_HOURS,
|
||||
@@ -68,13 +60,7 @@ 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_VOLT_AMPS,
|
||||
DEVICE_CLASS_APPARENT_POWER,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
("PEJP", UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT),
|
||||
("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
|
||||
],
|
||||
)
|
||||
def test_known_tag_preset(
|
||||
@@ -87,42 +73,6 @@ 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,5 +168,5 @@ def test_final_validate_needs_idf_5_5(
|
||||
) -> None:
|
||||
_set_variant(set_core_config, VARIANT_ESP32S3, idf=idf)
|
||||
usb_host.request_periodic_fifo(out_bytes=196)
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5.0 or newer"):
|
||||
with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"):
|
||||
usb_host.FINAL_VALIDATE_SCHEMA({})
|
||||
|
||||
@@ -14,8 +14,3 @@ esphome:
|
||||
then:
|
||||
- deep_sleep.enter:
|
||||
sleep_duration: 60min
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return deep_sleep::woke_from_deep_sleep();'
|
||||
then:
|
||||
- logger.log: Woke from deep sleep
|
||||
|
||||
@@ -1,74 +1,15 @@
|
||||
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;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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"
|
||||
@@ -1,7 +0,0 @@
|
||||
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,7 +1,3 @@
|
||||
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,4 +1,3 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
|
||||
ds3231_hub: !include hub-polling.yaml
|
||||
ds3231: !include common.yaml
|
||||
|
||||
@@ -19,6 +19,11 @@ image:
|
||||
} else {
|
||||
ESP_LOGD("online_image", "Cache miss: fresh download");
|
||||
}
|
||||
on_download_started:
|
||||
- logger.log: Download started
|
||||
on_download_progress:
|
||||
lambda: |-
|
||||
ESP_LOGD("online_image", "Downloaded %.0f%% (%zu bytes)", progress, bytes_read);
|
||||
- platform: online_image
|
||||
id: online_binary_transparent_image
|
||||
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -27,6 +28,9 @@ class FakeContainer : public http_request::HttpContainer {
|
||||
}
|
||||
|
||||
int read(uint8_t *buf, size_t max_len) override {
|
||||
this->largest_read = std::max(this->largest_read, max_len);
|
||||
if (this->read_error_)
|
||||
return -1;
|
||||
size_t len = std::min(max_len, this->available_ - this->bytes_read_);
|
||||
std::memcpy(buf, this->body_.data() + this->bytes_read_, len);
|
||||
this->bytes_read_ += len;
|
||||
@@ -42,13 +46,21 @@ class FakeContainer : public http_request::HttpContainer {
|
||||
}
|
||||
|
||||
void set_content_length(size_t length) { this->content_length = length; }
|
||||
/// Make every read from now on fail, like a dropped connection
|
||||
void fail_reads() { this->read_error_ = true; }
|
||||
/// Let len more bytes of the body arrive
|
||||
void deliver(size_t len) { this->available_ = std::min(this->body_.size(), this->available_ + len); }
|
||||
void add_response_header(const std::string &name, const std::string &value) {
|
||||
this->response_headers_.push_back({name, value});
|
||||
}
|
||||
|
||||
/// The most bytes any one read() asked for
|
||||
size_t largest_read{0};
|
||||
|
||||
protected:
|
||||
std::vector<uint8_t> body_;
|
||||
size_t available_;
|
||||
bool read_error_{false};
|
||||
};
|
||||
|
||||
class FakeHttpRequest : public http_request::HttpRequestComponent {
|
||||
@@ -123,13 +135,173 @@ static constexpr std::array<uint8_t, 69> PNG_1X1{
|
||||
0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9,
|
||||
0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82};
|
||||
|
||||
// 8x8 progressive JPEG, 521 bytes
|
||||
static const uint8_t JPEG_PROGRESSIVE[] = {
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10,
|
||||
0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33,
|
||||
0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67,
|
||||
0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11,
|
||||
0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00,
|
||||
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03,
|
||||
0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08,
|
||||
0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F,
|
||||
0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00,
|
||||
0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
|
||||
0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||
0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00,
|
||||
0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
|
||||
0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9,
|
||||
};
|
||||
|
||||
// 48x40 4:2:0 JPEG (quality 95), 2344 bytes: three rows of 16 pixel MCUs, with the scan starting at byte 609
|
||||
static const uint8_t JPEG_48X40[] = {
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02,
|
||||
0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05,
|
||||
0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02,
|
||||
0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00,
|
||||
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01,
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||
0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00,
|
||||
0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
|
||||
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62,
|
||||
0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A,
|
||||
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
|
||||
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6,
|
||||
0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7,
|
||||
0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7,
|
||||
0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00,
|
||||
0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03,
|
||||
0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
|
||||
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
|
||||
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09,
|
||||
0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A,
|
||||
0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A,
|
||||
0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96,
|
||||
0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7,
|
||||
0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
|
||||
0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9,
|
||||
0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xFC, 0xAA, 0xB8, 0xB7,
|
||||
0xD3, 0xB5, 0xD8, 0x1E, 0xD6, 0xD1, 0xA5, 0x5B, 0x4B, 0x46, 0x10, 0xC1, 0x06, 0xA1, 0x29, 0x69, 0x25, 0xB7, 0xC2,
|
||||
0xC6, 0x0C, 0x71, 0x26, 0x5E, 0x50, 0x15, 0x5D, 0xB6, 0x9C, 0x9E, 0x00, 0xC1, 0x67, 0x67, 0x4D, 0xCF, 0x84, 0xF7,
|
||||
0x57, 0xA6, 0xDA, 0x08, 0x52, 0xF5, 0xE5, 0xBA, 0xB8, 0x7B, 0x5B, 0x68, 0xEE, 0x2F, 0x66, 0x89, 0xB7, 0xB7, 0x9B,
|
||||
0xE5, 0x9C, 0x23, 0xAA, 0xFE, 0xF7, 0x68, 0x11, 0x9E, 0x5D, 0x18, 0x12, 0x48, 0x56, 0x5C, 0x9F, 0x57, 0xB6, 0xF8,
|
||||
0x33, 0xF0, 0xE2, 0x5B, 0xFB, 0xCF, 0xF4, 0x51, 0x0C, 0xC6, 0x64, 0x30, 0x49, 0x71, 0x7C, 0xE2, 0x16, 0x90, 0x2E,
|
||||
0xF4, 0x72, 0xEA, 0xE0, 0x34, 0x81, 0x77, 0x93, 0x9C, 0x07, 0xC2, 0x93, 0xB9, 0x61, 0xC0, 0xD3, 0xF0, 0x17, 0xC1,
|
||||
0xBF, 0x03, 0xCD, 0xE1, 0xAD, 0x42, 0x3F, 0x0A, 0xD8, 0xC7, 0x2D, 0xAC, 0x36, 0xCB, 0xF6, 0x79, 0x22, 0x69, 0x84,
|
||||
0xC4, 0x93, 0x29, 0x2A, 0x89, 0xB9, 0x00, 0x50, 0x62, 0x7C, 0xFF, 0x00, 0x16, 0xD9, 0x65, 0x00, 0x16, 0x65, 0x07,
|
||||
0xFA, 0x5B, 0x36, 0xE2, 0xAC, 0xBF, 0x2D, 0x55, 0x79, 0x29, 0xB5, 0x26, 0x9B, 0xA9, 0x18, 0xB5, 0x15, 0x36, 0x9B,
|
||||
0x53, 0xD1, 0xCA, 0x10, 0x93, 0x69, 0xB6, 0x94, 0x27, 0x16, 0xAF, 0x19, 0x29, 0xB8, 0xBB, 0x3F, 0x9A, 0xE1, 0x1E,
|
||||
0x2A, 0xCB, 0x9E, 0x79, 0x4A, 0x92, 0x84, 0xDA, 0xD6, 0xED, 0xA4, 0xED, 0x7A, 0x72, 0xB5, 0xAC, 0xF6, 0xEB, 0xAD,
|
||||
0xD5, 0x97, 0x91, 0xC9, 0x4D, 0xA4, 0xE9, 0xD7, 0x56, 0x45, 0x66, 0xD3, 0x9D, 0x85, 0xB1, 0x86, 0x59, 0x7E, 0xD9,
|
||||
0x7E, 0xA0, 0xE0, 0xA8, 0x02, 0x17, 0x02, 0x3C, 0x36, 0x59, 0x18, 0xE2, 0x42, 0x1D, 0x40, 0x56, 0x0A, 0x77, 0x30,
|
||||
0xAE, 0xFA, 0x56, 0xD4, 0xA4, 0x8A, 0xD9, 0xAD, 0xD6, 0x38, 0x1D, 0x7F, 0x77, 0x77, 0x1C, 0xB2, 0x4C, 0x22, 0x8C,
|
||||
0x2B, 0xF3, 0x14, 0x64, 0x45, 0x85, 0x52, 0x47, 0x92, 0x7E, 0xF2, 0x0F, 0x34, 0x1D, 0xBC, 0x56, 0xDE, 0x85, 0xF0,
|
||||
0x4B, 0xE1, 0x0A, 0xE9, 0xB0, 0xDB, 0x2D, 0x8C, 0x4C, 0xCD, 0x74, 0xF1, 0x42, 0x96, 0x93, 0xCF, 0xF6, 0x54, 0x0C,
|
||||
0xB3, 0x44, 0x03, 0xAA, 0x6C, 0x49, 0x10, 0x87, 0x00, 0x88, 0xC2, 0xEE, 0x66, 0x6F, 0x9B, 0xAB, 0x57, 0xA4, 0x68,
|
||||
0xDF, 0x0A, 0x3E, 0x1B, 0xDA, 0xE9, 0xDF, 0xD9, 0x96, 0xFE, 0x03, 0xB2, 0x8E, 0xD9, 0x8A, 0xDD, 0xC1, 0x77, 0x14,
|
||||
0x97, 0x50, 0x2E, 0xE3, 0x20, 0x62, 0xA0, 0x33, 0x0C, 0xC6, 0x58, 0x48, 0x36, 0x6D, 0xC1, 0x20, 0xA2, 0x2B, 0x00,
|
||||
0xB5, 0xF1, 0x99, 0xCF, 0x19, 0x65, 0x79, 0xAD, 0x0C, 0x3C, 0x2B, 0x61, 0xF9, 0x28, 0xD6, 0x52, 0x97, 0x35, 0x95,
|
||||
0xD4, 0x61, 0x28, 0x5B, 0x99, 0xB8, 0xA9, 0xA9, 0x47, 0xD9, 0x3A, 0x69, 0xA9, 0xC6, 0x51, 0x52, 0x6A, 0x0D, 0x4A,
|
||||
0x74, 0xA9, 0xC3, 0xFA, 0x7F, 0xC3, 0xFE, 0x25, 0xC1, 0x4E, 0xAE, 0x26, 0x2E, 0x13, 0x71, 0x5C, 0x8D, 0xFB, 0xB1,
|
||||
0xD6, 0xEA, 0x6F, 0xF9, 0xAF, 0xBE, 0xB7, 0x4B, 0x55, 0x1D, 0x75, 0x4C, 0xF3, 0xDF, 0x05, 0xD9, 0xF8, 0x7B, 0xC3,
|
||||
0xBA, 0xB5, 0xF1, 0xBC, 0x8D, 0x63, 0xDF, 0x1C, 0x32, 0x88, 0x1A, 0xF3, 0x74, 0x73, 0x89, 0x15, 0xC4, 0x8A, 0x25,
|
||||
0x66, 0xDC, 0xAA, 0x98, 0x60, 0x06, 0x7F, 0xBD, 0xF2, 0x81, 0xF3, 0x36, 0xFE, 0x9D, 0xAF, 0x78, 0x8F, 0x54, 0xD3,
|
||||
0xAD, 0xA6, 0x9E, 0xC5, 0xE5, 0x9C, 0x96, 0xDC, 0xB7, 0xE8, 0x84, 0x47, 0xFB, 0xB4, 0x54, 0x42, 0x0F, 0x03, 0xF8,
|
||||
0xD7, 0xE5, 0x39, 0x60, 0xF8, 0x04, 0x79, 0x7B, 0xE3, 0xF4, 0x9F, 0x85, 0x5F, 0x08, 0xBE, 0x0D, 0x2C, 0x57, 0xCE,
|
||||
0x6C, 0xA5, 0x2B, 0xA8, 0x08, 0x20, 0x82, 0x04, 0xB9, 0xB8, 0x82, 0x43, 0x28, 0x72, 0x55, 0xD5, 0x83, 0xB9, 0x05,
|
||||
0xC2, 0x44, 0x55, 0x77, 0x8C, 0xFC, 0xEC, 0xD8, 0xCB, 0xE7, 0xBE, 0x9B, 0xE0, 0x5F, 0xC3, 0x9D, 0x32, 0xD4, 0x45,
|
||||
0x26, 0x8B, 0x2C, 0x93, 0xC0, 0xF3, 0x95, 0x49, 0xEE, 0x2E, 0x0C, 0x26, 0x26, 0x31, 0xC9, 0xF3, 0xED, 0x95, 0x96,
|
||||
0x32, 0xEC, 0x10, 0xE1, 0xB8, 0x60, 0x3A, 0x90, 0x1B, 0x3F, 0x9E, 0x66, 0x58, 0xFC, 0x14, 0x31, 0x15, 0xFD, 0x9C,
|
||||
0x79, 0x57, 0x3A, 0x92, 0x84, 0x1C, 0x3D, 0xC9, 0xCD, 0x27, 0x6D, 0x12, 0xE5, 0xE7, 0xA6, 0xD5, 0x4D, 0x64, 0x93,
|
||||
0xD1, 0x45, 0xC6, 0xAD, 0xE7, 0x2F, 0xE9, 0xEE, 0x17, 0xE3, 0x0C, 0x0A, 0xE1, 0x9C, 0x37, 0x34, 0x67, 0x2B, 0x73,
|
||||
0x2B, 0xB4, 0xBA, 0x49, 0xF4, 0x72, 0x57, 0x51, 0xDD, 0x24, 0x9B, 0x5A, 0x36, 0x9A, 0x49, 0x1E, 0x15, 0xA7, 0x7E,
|
||||
0xCE, 0xF6, 0x89, 0x6A, 0x96, 0x43, 0x55, 0xB5, 0xB3, 0xB6, 0xB8, 0x96, 0x2B, 0x9B, 0x78, 0xA0, 0xB4, 0x9E, 0xE5,
|
||||
0x61, 0x90, 0xE0, 0xC2, 0xEC, 0x61, 0x91, 0x47, 0xCC, 0xDB, 0xF1, 0x80, 0xC0, 0x98, 0xD4, 0xE5, 0xDC, 0x64, 0xEC,
|
||||
0x7C, 0x29, 0xFD, 0x98, 0x6C, 0xAF, 0xEC, 0xF5, 0x76, 0xB7, 0xF1, 0x36, 0x9A, 0x5D, 0x6F, 0x96, 0xE1, 0x74, 0xB5,
|
||||
0xD3, 0x44, 0x91, 0xA8, 0x45, 0x0C, 0xE2, 0x3D, 0xAC, 0x85, 0xF9, 0x3B, 0x5B, 0x19, 0x8A, 0x43, 0x1F, 0xCA, 0x50,
|
||||
0x9D, 0x87, 0xD2, 0x0F, 0xC2, 0x1F, 0x89, 0x4F, 0x6D, 0x04, 0x0F, 0xE1, 0x61, 0xAA, 0xEE, 0xB4, 0x8E, 0x56, 0xFB,
|
||||
0x06, 0xAF, 0x04, 0xF3, 0x40, 0x9B, 0x55, 0x4E, 0xCB, 0x66, 0x91, 0xCB, 0x18, 0xC2, 0x88, 0xB6, 0xB1, 0x65, 0x60,
|
||||
0xA4, 0x6E, 0xDC, 0xA4, 0x9E, 0xAF, 0xE1, 0xBF, 0xC2, 0x2F, 0x89, 0xA9, 0xE1, 0xFB, 0x8D, 0x6E, 0xC2, 0x3B, 0x4B,
|
||||
0x30, 0xD7, 0x60, 0xDA, 0x41, 0x2D, 0xCC, 0x6C, 0x2E, 0x49, 0xF3, 0x1E, 0x42, 0x19, 0x5D, 0x58, 0x3C, 0x8A, 0x81,
|
||||
0x82, 0xB8, 0x0C, 0xC9, 0xF2, 0xE5, 0x8F, 0x03, 0x8F, 0x36, 0xE3, 0x8A, 0x98, 0xCC, 0x14, 0xAB, 0x62, 0x2B, 0xC2,
|
||||
0x34, 0xA2, 0xA5, 0x78, 0x37, 0x42, 0x12, 0x70, 0x8C, 0x62, 0xD2, 0x6D, 0x72, 0x7B, 0xAF, 0x9B, 0xDD, 0xFD, 0xE5,
|
||||
0x4E, 0x79, 0x49, 0x45, 0x73, 0xA9, 0x72, 0x2F, 0xF0, 0x73, 0x83, 0xF8, 0xCE, 0xB4, 0x33, 0xE8, 0x3F, 0xAE, 0x42,
|
||||
0xF7, 0x76, 0xB3, 0x83, 0x57, 0xE5, 0x95, 0xBE, 0xCE, 0x8F, 0x7B, 0xDE, 0xD7, 0x4B, 0x6B, 0x9C, 0x1E, 0x8D, 0xF0,
|
||||
0x56, 0x7B, 0xDD, 0x26, 0x34, 0x5F, 0x1F, 0xDB, 0x9D, 0x37, 0x53, 0xB8, 0x96, 0xED, 0xA0, 0x16, 0xB1, 0xC9, 0x2C,
|
||||
0x8B, 0xF2, 0x26, 0x58, 0x89, 0x37, 0xEE, 0x04, 0xB2, 0x97, 0x54, 0x2D, 0x88, 0xC0, 0x60, 0x0B, 0x03, 0x5E, 0x9B,
|
||||
0x67, 0xFB, 0x36, 0xE9, 0x31, 0x2D, 0x9C, 0x37, 0x9E, 0x32, 0x84, 0x5D, 0x2C, 0x4A, 0xD6, 0xAD, 0x73, 0x63, 0x28,
|
||||
0x36, 0xAA, 0x53, 0xF7, 0x90, 0x29, 0x8E, 0x5C, 0x92, 0xBE, 0x41, 0xDC, 0xC0, 0xB3, 0x2B, 0x46, 0x32, 0x4B, 0x96,
|
||||
0x35, 0xAD, 0x1F, 0xC2, 0xDF, 0x89, 0x17, 0xF6, 0xB6, 0xFA, 0xB5, 0xED, 0xB8, 0xB7, 0x8E, 0x78, 0x66, 0x96, 0x09,
|
||||
0x6C, 0x6E, 0x23, 0x32, 0x28, 0x08, 0xE8, 0xF2, 0xBA, 0xF9, 0x86, 0x48, 0xC0, 0x68, 0xFE, 0x63, 0xC8, 0x23, 0x82,
|
||||
0xCC, 0xA5, 0x4A, 0xFA, 0x66, 0x8F, 0xF0, 0x37, 0xE2, 0x6D, 0xE5, 0xC9, 0x3A, 0x87, 0x86, 0xB4, 0xFB, 0x28, 0xAE,
|
||||
0x61, 0x0F, 0x0B, 0x59, 0xDF, 0xC0, 0xD1, 0xAC, 0xE0, 0x24, 0x41, 0x42, 0xC4, 0x49, 0x1B, 0x10, 0x21, 0x50, 0xC0,
|
||||
0x09, 0x11, 0x8B, 0xEE, 0x6E, 0x71, 0xE2, 0xE2, 0x78, 0xBB, 0x33, 0xFA, 0xC5, 0x1A, 0xB4, 0xEA, 0xC6, 0x74, 0xD2,
|
||||
0x8C, 0x24, 0xA5, 0x2A, 0x3C, 0xB5, 0x2E, 0xB9, 0x9B, 0x71, 0x97, 0x34, 0xE0, 0xAA, 0x54, 0x93, 0x71, 0x56, 0x49,
|
||||
0xB9, 0xC6, 0x4E, 0x9A, 0x9B, 0xD3, 0xFA, 0x7F, 0x81, 0xB8, 0xD6, 0xAC, 0xDD, 0x6F, 0x65, 0x8D, 0x82, 0x77, 0x85,
|
||||
0x95, 0xE1, 0x7D, 0x1C, 0xD2, 0x8B, 0x7A, 0x27, 0xA2, 0xF4, 0xED, 0xEE, 0x9E, 0x67, 0xE0, 0x8F, 0x81, 0x37, 0xBA,
|
||||
0xEF, 0x87, 0x67, 0xBF, 0x8F, 0xC4, 0xD2, 0xDD, 0x4F, 0x05, 0xA8, 0x61, 0x7B, 0x1E, 0x9E, 0xE1, 0x8C, 0xEA, 0xF3,
|
||||
0x18, 0x99, 0xA3, 0xDE, 0x76, 0x66, 0x36, 0x2A, 0x30, 0xFB, 0x9B, 0x0F, 0xC3, 0x1C, 0xA3, 0x76, 0x4B, 0xFB, 0x3B,
|
||||
0xE8, 0xBA, 0x66, 0xBF, 0x0E, 0x93, 0xFD, 0xAB, 0x23, 0xD9, 0xA5, 0xCE, 0x62, 0xF3, 0x34, 0xEB, 0x7F, 0x29, 0x0A,
|
||||
0xA7, 0x04, 0x48, 0xF2, 0x79, 0x82, 0x42, 0x1D, 0x46, 0x4B, 0xA9, 0x1B, 0xD1, 0xBE, 0x52, 0x54, 0xD7, 0xA8, 0xF8,
|
||||
0x33, 0xE1, 0xAF, 0xC4, 0xA8, 0x24, 0xD6, 0x6D, 0xED, 0xBC, 0x0A, 0xD3, 0x5A, 0x5C, 0xC4, 0xDF, 0x64, 0x17, 0x97,
|
||||
0x36, 0xF0, 0x65, 0x52, 0x39, 0xD4, 0x23, 0x22, 0x9D, 0xD8, 0x64, 0x83, 0x04, 0x85, 0x38, 0xEF, 0xB8, 0x49, 0x91,
|
||||
0xBB, 0xFF, 0x00, 0x0A, 0xDF, 0xE3, 0x77, 0x84, 0xAC, 0xD3, 0x4A, 0xBC, 0xF0, 0x3A, 0x5A, 0x5B, 0x4F, 0x72, 0x5E,
|
||||
0xD7, 0x53, 0x8E, 0xE2, 0x12, 0x6D, 0xC6, 0x40, 0x20, 0x47, 0xE6, 0x3E, 0x71, 0x26, 0x70, 0x24, 0xE4, 0xB4, 0x63,
|
||||
0x2A, 0xC0, 0xE0, 0x7E, 0x71, 0x98, 0x71, 0x35, 0x5C, 0x4E, 0x69, 0x5A, 0xBD, 0x7C, 0x7A, 0x9C, 0xE3, 0xC9, 0xCF,
|
||||
0x38, 0x46, 0x8D, 0xA4, 0xDC, 0x63, 0x38, 0x4A, 0xE9, 0xED, 0x5A, 0x4E, 0x4A, 0x6D, 0xF3, 0xCA, 0x2D, 0xB9, 0x4D,
|
||||
0xD3, 0x77, 0x8C, 0x7F, 0xA8, 0xF8, 0x53, 0x8D, 0x31, 0x32, 0xC8, 0xA9, 0xDB, 0x15, 0x04, 0x9F, 0xB4, 0xD2, 0xF4,
|
||||
0xD3, 0xF8, 0xA5, 0x77, 0xCD, 0xB3, 0x49, 0x6C, 0xB4, 0xEA, 0x97, 0x46, 0x78, 0x45, 0xC4, 0x16, 0x5A, 0xDD, 0xA5,
|
||||
0xA6, 0x8D, 0xA7, 0xEA, 0x77, 0x4A, 0x54, 0x47, 0x3C, 0x31, 0x41, 0x20, 0x06, 0x68, 0x00, 0x9A, 0x4F, 0x2A, 0x40,
|
||||
0x5D, 0x5F, 0x69, 0xF3, 0x15, 0xCB, 0x32, 0x96, 0xDC, 0xC0, 0x90, 0xA5, 0xF0, 0xBD, 0xAF, 0xC3, 0x96, 0x83, 0x51,
|
||||
0xB8, 0xD6, 0xA7, 0x48, 0xD2, 0xE8, 0x2B, 0x2C, 0x2D, 0x6B, 0xF6, 0xA4, 0x55, 0x8E, 0x45, 0x5F, 0xBA, 0x54, 0x2A,
|
||||
0x36, 0x77, 0x12, 0x72, 0xE5, 0x9C, 0x14, 0x3B, 0x63, 0x6C, 0x62, 0x8A, 0x2B, 0xDE, 0xCD, 0x70, 0x74, 0xA7, 0x92,
|
||||
0xE2, 0x31, 0x35, 0x2D, 0x29, 0x54, 0xBB, 0x97, 0xBB, 0x18, 0xF3, 0x38, 0x52, 0xA5, 0x38, 0xDF, 0x92, 0x31, 0x7E,
|
||||
0xEB, 0x9B, 0x6A, 0xCD, 0x6B, 0xEF, 0x7C, 0x6E, 0x52, 0x7F, 0xE1, 0xF7, 0x0E, 0x54, 0x96, 0x1B, 0x3D, 0x52, 0x86,
|
||||
0xF0, 0x73, 0x4B, 0xEE, 0x92, 0xD6, 0xD6, 0xBF, 0xC2, 0x8D, 0x9F, 0x0B, 0xDE, 0xEB, 0xF3, 0x69, 0x6B, 0x06, 0x9B,
|
||||
0xA9, 0x5B, 0xE9, 0xF6, 0x6F, 0x76, 0x1E, 0x45, 0x8E, 0xE1, 0x54, 0xC8, 0xCD, 0xF3, 0x28, 0x8B, 0x1C, 0x9E, 0x25,
|
||||
0x55, 0xDA, 0xA5, 0x58, 0x80, 0xCA, 0x46, 0x42, 0xA8, 0xF4, 0x1B, 0xEB, 0xBB, 0xBB, 0x8B, 0x69, 0xAF, 0xA5, 0x8E,
|
||||
0xD0, 0x49, 0x25, 0x9C, 0x37, 0x73, 0xE9, 0xD7, 0xF2, 0x14, 0x8E, 0x55, 0x91, 0x64, 0x90, 0x46, 0xA8, 0xF9, 0x50,
|
||||
0x57, 0x73, 0x2E, 0xE0, 0x55, 0xF6, 0x87, 0x45, 0x20, 0x2E, 0xC7, 0x28, 0xAF, 0xCD, 0x78, 0xA6, 0xA5, 0x5C, 0x26,
|
||||
0x33, 0x01, 0x35, 0x2E, 0x67, 0x52, 0x35, 0xA0, 0xEE, 0x93, 0x56, 0x70, 0x4F, 0x48, 0xDB, 0x95, 0x6B, 0x76, 0xF4,
|
||||
0xB4, 0xA5, 0x27, 0x29, 0x29, 0x35, 0x16, 0xBF, 0xA8, 0x7C, 0x3E, 0x92, 0xF6, 0x98, 0xA8, 0x5B, 0x4B, 0xC5, 0xDB,
|
||||
0x5E, 0x8E, 0x4F, 0x7D, 0xFA, 0xDB, 0x57, 0xB2, 0x56, 0xD6, 0xED, 0xF7, 0x3E, 0x10, 0xB2, 0x82, 0xF6, 0xC2, 0xF2,
|
||||
0xF3, 0x4E, 0xD4, 0xAF, 0xEE, 0x5A, 0xCA, 0xCE, 0xD2, 0xF1, 0xA7, 0xB8, 0x9D, 0xD4, 0x42, 0x8C, 0xF2, 0x3C, 0x8E,
|
||||
0x42, 0x02, 0x24, 0xFD, 0xDA, 0x96, 0x60, 0xAD, 0xB0, 0xC9, 0x0E, 0x00, 0xC8, 0xC8, 0xEA, 0xEE, 0x12, 0x7B, 0x09,
|
||||
0x6C, 0x6E, 0xCE, 0xA2, 0x0B, 0xAA, 0x32, 0xD9, 0xB9, 0x49, 0x66, 0xDF, 0x74, 0xCC, 0x31, 0x1B, 0xE4, 0xE6, 0x63,
|
||||
0xF2, 0x91, 0x95, 0x42, 0x15, 0x8B, 0x7D, 0xE0, 0xAC, 0xB4, 0x51, 0x5F, 0x33, 0x3A, 0xD8, 0x8C, 0x46, 0x7A, 0x9C,
|
||||
0xA6, 0xD7, 0x2C, 0x13, 0x49, 0x25, 0x6E, 0x6A, 0x73, 0xE4, 0x84, 0x9E, 0x9A, 0xC9, 0x29, 0x24, 0x9E, 0xE9, 0x42,
|
||||
0x11, 0x8D, 0xA2, 0x9C, 0x5F, 0xF5, 0x37, 0x0D, 0x36, 0xF8, 0x7E, 0x9A, 0xEC, 0xE5, 0xE5, 0x7E, 0x67, 0x77, 0x7B,
|
||||
0x7A, 0xBF, 0x5E, 0xB7, 0x3F, 0xFF, 0xD9,
|
||||
};
|
||||
|
||||
static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); }
|
||||
|
||||
class OnlineImageDownloadEnd : public ::testing::Test {
|
||||
protected:
|
||||
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format) {
|
||||
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format, uint32_t buffer_size = 256) {
|
||||
auto img = std::make_unique<OnlineImage>("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB,
|
||||
image::TRANSPARENCY_OPAQUE, nullptr, 256);
|
||||
image::TRANSPARENCY_OPAQUE, nullptr, buffer_size);
|
||||
img->set_parent(&this->http_);
|
||||
return img;
|
||||
}
|
||||
@@ -286,4 +458,294 @@ TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) {
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// The JPEG decoded with all of it in memory at once, to compare the downloaded images with
|
||||
static std::vector<uint8_t> reference_jpeg_pixels() {
|
||||
runtime_image::RuntimeImage ref(runtime_image::JPEG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
|
||||
std::vector<uint8_t> data(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
EXPECT_TRUE(ref.begin_decode(data.size()));
|
||||
EXPECT_EQ(ref.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
|
||||
EXPECT_TRUE(ref.end_decode());
|
||||
return std::vector<uint8_t>(ref.get_data_start(), ref.get_data_start() + ref.get_width_stride() * ref.get_height());
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> image_pixels(OnlineImage &image) {
|
||||
return std::vector<uint8_t>(image.get_data_start(),
|
||||
image.get_data_start() + image.get_width_stride() * image.get_height());
|
||||
}
|
||||
|
||||
// A JPEG of known size is decoded as it arrives, so a buffer smaller than the file only has to hold the headers
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDecodesThroughASmallBuffer) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
// Bytes after the image are never needed. Waiting for the whole file before decoding would read them all.
|
||||
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
body.resize(body.size() + 8192, 0);
|
||||
auto container = std::make_shared<FakeContainer>(body, body.size(), false);
|
||||
container->add_response_header("etag", "\"abc\"");
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_LT(container->get_bytes_read(), body.size()) << "the image was not decoded as it arrived";
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
ASSERT_EQ(image->get_width(), 48);
|
||||
ASSERT_EQ(image->get_height(), 40);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
|
||||
// A complete image is cached
|
||||
this->http_.next = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
|
||||
image->update();
|
||||
EXPECT_TRUE(this->http_.sent_header("if-none-match"));
|
||||
}
|
||||
|
||||
// A read() waits for every byte it asks for, so a large buffer is filled a little at a time rather than in one
|
||||
// read that blocks the loop until the whole buffer has arrived
|
||||
TEST_F(OnlineImageDownloadEnd, JpegReadsInSmallPiecesWithALargeBuffer) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 65536);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 0, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
container->deliver(sizeof(JPEG_48X40));
|
||||
image->loop();
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_LE(container->largest_read, READ_CHUNK);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// A downloaded PNG is read a little at a time too
|
||||
TEST_F(OnlineImageDownloadEnd, PngReadsInSmallPiecesWithALargeBuffer) {
|
||||
auto image = this->make_image_(runtime_image::PNG, 65536);
|
||||
std::vector<uint8_t> png(PNG_1X1.begin(), PNG_1X1.end());
|
||||
auto container = std::make_shared<FakeContainer>(png, png.size(), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_LE(container->largest_read, READ_CHUNK);
|
||||
}
|
||||
|
||||
// A slow link: the decoder waits part way through the image for the rest to arrive
|
||||
TEST_F(OnlineImageDownloadEnd, JpegWaitsForASlowDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
int passes = 0;
|
||||
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
|
||||
image->loop();
|
||||
if (passes == 5) {
|
||||
EXPECT_EQ(this->finished_, 0) << "the image cannot be finished before the rest of it arrives";
|
||||
}
|
||||
container->deliver(40);
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_GT(passes, 10);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// A download that stops part way through times out rather than waiting for ever
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDownloadThatStopsTimesOut) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1500, false);
|
||||
set_loop_time(1000);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 0) << "still within the timeout";
|
||||
|
||||
set_loop_time(1000 + this->http_.get_timeout());
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// Headers that do not fit the download buffer cannot be read as the image arrives, so the whole image is
|
||||
// downloaded first, keeping the bytes already read
|
||||
TEST_F(OnlineImageDownloadEnd, JpegHeadersLargerThanTheBufferDownloadTheWholeImage) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 256);
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
image->loop();
|
||||
container->deliver(256);
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_EQ(container->get_bytes_read(), sizeof(JPEG_48X40));
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// The pass that opens the image does not decode any of it
|
||||
TEST_F(OnlineImageDownloadEnd, JpegOpensInAPassOfItsOwn) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
image->loop();
|
||||
EXPECT_EQ(this->finished_ + this->errors_, 0);
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(this->finished_, 1);
|
||||
}
|
||||
|
||||
// A progressive JPEG is refused while it downloads, without blaming the buffer size
|
||||
TEST_F(OnlineImageDownloadEnd, JpegProgressiveReportsError) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 256);
|
||||
std::vector<uint8_t> body(JPEG_PROGRESSIVE, JPEG_PROGRESSIVE + sizeof(JPEG_PROGRESSIVE));
|
||||
auto container = std::make_shared<FakeContainer>(body, 256, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// on_download_started fires once the server has accepted the request, before the first progress report, and not
|
||||
// for a response that has no image to download
|
||||
TEST_F(OnlineImageDownloadEnd, StartedFiresOnlyWhenTheDownloadBegins) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<std::string> events;
|
||||
image->add_on_download_started_callback([&events]() { events.emplace_back("started"); });
|
||||
image->add_on_progress_callback([&events](float, size_t) { events.emplace_back("progress"); });
|
||||
this->start_(*image,
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false));
|
||||
ASSERT_GE(events.size(), 2u);
|
||||
EXPECT_EQ(events[0], "started");
|
||||
EXPECT_EQ(events[1], "progress");
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(std::count(events.begin(), events.end(), std::string("started")), 1);
|
||||
|
||||
for (int status : {304, 404}) {
|
||||
events.clear();
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
|
||||
container->status_code = status;
|
||||
this->http_.next = container;
|
||||
image->update();
|
||||
EXPECT_TRUE(events.empty()) << "HTTP " << status;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress starts at 0, is reported at most every 100 ms, and always reaches 100%
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressIsReportedAsTheImageDownloads) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<float> reports;
|
||||
image->add_on_progress_callback([&reports](float progress, size_t) { reports.push_back(progress); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 100, false);
|
||||
uint32_t now = 1000;
|
||||
set_loop_time(now);
|
||||
this->start_(*image, container);
|
||||
ASSERT_EQ(reports.size(), 1u);
|
||||
EXPECT_EQ(reports[0], 0.0f);
|
||||
|
||||
int passes = 0;
|
||||
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
|
||||
now += 50;
|
||||
set_loop_time(now);
|
||||
container->deliver(100);
|
||||
image->loop();
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(reports.back(), 100.0f);
|
||||
EXPECT_TRUE(std::is_sorted(reports.begin(), reports.end()));
|
||||
// A report every 100 ms of a 50 ms loop, plus the start and the end
|
||||
EXPECT_LE(reports.size(), static_cast<size_t>(passes / 2 + 2));
|
||||
EXPECT_GT(reports.size(), 3u);
|
||||
}
|
||||
|
||||
// Without a Content-Length the percentage is unknown, but the bytes read are still reported
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressOfAChunkedDownloadHasNoPercentage) {
|
||||
auto image = this->make_image_(runtime_image::PNG);
|
||||
std::vector<std::pair<float, size_t>> reports;
|
||||
image->add_on_progress_callback(
|
||||
[&reports](float progress, size_t bytes_read) { reports.emplace_back(progress, bytes_read); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(PNG_1X1.begin(), PNG_1X1.end()), PNG_1X1.size(), true);
|
||||
set_loop_time(1000);
|
||||
this->start_(*image, container);
|
||||
set_loop_time(2000);
|
||||
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_GE(reports.size(), 2u);
|
||||
EXPECT_TRUE(std::isnan(reports.back().first));
|
||||
EXPECT_EQ(reports.back().second, PNG_1X1.size());
|
||||
}
|
||||
|
||||
// Scan data that cannot be decoded fails the download part way through
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDecodeErrorFailsTheDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
std::fill(body.begin() + 700, body.end() - 2, 0xFE);
|
||||
auto container = std::make_shared<FakeContainer>(body, 1024, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
image->loop();
|
||||
container->deliver(256);
|
||||
}
|
||||
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// A read that fails part way through the download ends it with an error
|
||||
TEST_F(OnlineImageDownloadEnd, JpegReadErrorFailsTheDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
|
||||
this->start_(*image, container);
|
||||
for (int i = 0; i < 5; i++)
|
||||
image->loop();
|
||||
ASSERT_EQ(this->errors_, 0);
|
||||
|
||||
container->fail_reads();
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
TEST(DownloadBufferGrow, KeepsTheUnreadData) {
|
||||
DownloadBuffer buffer(16);
|
||||
std::memcpy(buffer.append(), "abcdef", 6);
|
||||
buffer.write(6);
|
||||
buffer.read(2);
|
||||
|
||||
ASSERT_TRUE(buffer.grow(64));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
ASSERT_EQ(buffer.unread(), 4u);
|
||||
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
|
||||
|
||||
// A failed allocation leaves the buffer as it was
|
||||
EXPECT_FALSE(buffer.grow(SIZE_MAX / 2));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
|
||||
// Growing to a smaller size changes nothing
|
||||
EXPECT_TRUE(buffer.grow(8));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
}
|
||||
|
||||
} // namespace esphome::online_image::testing
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
@@ -362,6 +364,23 @@ static std::vector<uint8_t> pixel_bytes(TestableRuntimeImage &img) {
|
||||
return std::vector<uint8_t>(start, start + img.get_width_stride() * img.get_height());
|
||||
}
|
||||
|
||||
// Shrinking a PNG keeps the first source pixel of each span, as the JPEG path does: the even rows and columns
|
||||
TEST(RuntimeImageDecoder, ShrunkPngKeepsTheFirstPixelOfEachSpan) {
|
||||
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, 2, 2);
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
ASSERT_EQ(img.get_width(), 2);
|
||||
ASSERT_EQ(img.get_height(), 2);
|
||||
for (int row = 0; row < 2; row++) {
|
||||
for (int col = 0; col < 2; col++) {
|
||||
const Color pixel = img.get_pixel(col, row);
|
||||
const uint8_t *expected = PNG_RGB_EXPECTED[row * 2][col * 2];
|
||||
EXPECT_EQ(pixel.r, expected[0]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.g, expected[1]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.b, expected[2]) << col << "," << row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(JPEG);
|
||||
|
||||
@@ -377,6 +396,12 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
|
||||
}
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
}
|
||||
|
||||
// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each
|
||||
// channel is checked within one step; the byte order is still exact
|
||||
static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) {
|
||||
@@ -386,16 +411,12 @@ static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *o
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those
|
||||
// pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source
|
||||
// pixel (2, 0), which is word 0x3803 here.
|
||||
static const uint16_t EXPECTED[4 * 4] = {
|
||||
0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F,
|
||||
0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17,
|
||||
};
|
||||
// Halving the 8x8 gradient makes JPEGDEC decode it at half size, which averages each 2x2 block of pixels
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
|
||||
TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4);
|
||||
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(rgb565_little_endian.get_width(), 4);
|
||||
@@ -405,9 +426,53 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
|
||||
const uint8_t *little = rgb565_little_endian.get_data_start();
|
||||
const uint8_t *big = rgb565_big_endian.get_data_start();
|
||||
for (size_t i = 0; i < 4 * 4; i++) {
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i);
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
const uint16_t word = rgb565_word(full, col * 2 + dx, row * 2 + dy);
|
||||
red += word >> 11;
|
||||
green += (word >> 5) & 0x3F;
|
||||
blue += word & 0x1F;
|
||||
}
|
||||
}
|
||||
const uint16_t expected = ((red + 2) / 4) << 11 | ((green + 2) / 4) << 5 | (blue + 2) / 4;
|
||||
const size_t i = row * 4 + col;
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), expected, "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], expected, "big-endian", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic draw() path gets JPEGDEC's half size RGB8888 output, so it too averages each 2x2 block of pixels
|
||||
TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB, false, 8, 8);
|
||||
TestableRuntimeImage half(JPEG, image::IMAGE_TYPE_RGB, false, 4, 4);
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(half, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(static_cast<JpegDecoder *>(half.decoder())->get_scale(), 2);
|
||||
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
const Color pixel = full.get_pixel(col * 2 + dx, row * 2 + dy);
|
||||
red += pixel.r;
|
||||
green += pixel.g;
|
||||
blue += pixel.b;
|
||||
}
|
||||
}
|
||||
const Color pixel = half.get_pixel(col, row);
|
||||
EXPECT_NEAR(pixel.r, (red + 2) / 4, 8) << "pixel " << col << "," << row << " red";
|
||||
EXPECT_NEAR(pixel.g, (green + 2) / 4, 8) << "pixel " << col << "," << row << " green";
|
||||
EXPECT_NEAR(pixel.b, (blue + 2) / 4, 8) << "pixel " << col << "," << row << " blue";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,10 +480,15 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
|
||||
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
// JPEGDEC decodes at the smallest of full, 1/2, 1/4 and 1/8 size that still covers the buffer
|
||||
TEST(RuntimeImageDecoder, JpegDecodesAtTheSmallestSizeThatCoversTheBuffer) {
|
||||
for (auto [size, scale] :
|
||||
{std::pair{8, 1}, std::pair{5, 1}, std::pair{4, 2}, std::pair{3, 2}, std::pair{2, 4}, std::pair{1, 8}}) {
|
||||
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
EXPECT_EQ(img.get_width(), size);
|
||||
EXPECT_EQ(static_cast<JpegDecoder *>(img.decoder())->get_scale(), scale) << size << "x" << size;
|
||||
}
|
||||
}
|
||||
|
||||
// Every scaled direct decode must equal the unscaled one sampled through the boundary table,
|
||||
@@ -427,7 +497,8 @@ TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
|
||||
for (int size : {4, 6, 16}) {
|
||||
// Sizes JPEGDEC cannot reach by scaling, so the full size decode is spread over the buffer
|
||||
for (int size : {5, 6, 16}) {
|
||||
TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(scaled.get_width(), size);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
|
||||
class GrayscaleImage : public RuntimeImage {
|
||||
public:
|
||||
GrayscaleImage() : RuntimeImage(BMP, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE) {}
|
||||
uint8_t pixel(int x) const { return this->buffer_[x]; }
|
||||
};
|
||||
|
||||
// The integer weights stay within one step of the floating point formula they replace, and keep white at 255
|
||||
TEST(GrayscalePixel, MatchesTheFloatingPointWeights) {
|
||||
GrayscaleImage img;
|
||||
ASSERT_GT(img.resize(256, 1), 0);
|
||||
for (int r = 0; r < 256; r += 5) {
|
||||
for (int g = 0; g < 256; g += 5) {
|
||||
for (int b = 0; b < 256; b += 5) {
|
||||
img.draw_pixel(0, 0, Color(r, g, b));
|
||||
const int expected = static_cast<int>(0.2125 * r + 0.7154 * g + 0.0721 * b);
|
||||
ASSERT_LE(std::abs(img.pixel(0) - expected), 1) << "r=" << r << " g=" << g << " b=" << b;
|
||||
}
|
||||
}
|
||||
}
|
||||
img.draw_pixel(1, 0, Color(255, 255, 255));
|
||||
EXPECT_EQ(img.pixel(1), 255);
|
||||
img.draw_pixel(2, 0, Color(0, 0, 0));
|
||||
EXPECT_EQ(img.pixel(2), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image::testing
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
@@ -178,6 +179,7 @@ class SourceImage : public RuntimeImage {
|
||||
explicit SourceImage(image::ImageType type = image::IMAGE_TYPE_RGB, int width = 0, int height = 0)
|
||||
: RuntimeImage(JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {}
|
||||
size_t decoded_bytes() const { return this->decoded_bytes_; }
|
||||
int scale() const { return static_cast<const JpegDecoder *>(this->decoder_.get())->get_scale(); }
|
||||
};
|
||||
|
||||
/// The file in memory; fails reads once fail_at bytes have been read
|
||||
@@ -291,6 +293,23 @@ TEST(JpegSourceDecode, SteppedDirectRgb565MatchesFedDecode) {
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed));
|
||||
}
|
||||
|
||||
// decodeBegin() is given the same reduced scale as decode(), and decodeRows() keeps it for every step
|
||||
TEST(JpegSourceDecode, SteppedScaledDecodeMatchesFedDecode) {
|
||||
for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) {
|
||||
// 24x20 is half of 48x40, so JPEGDEC decodes at 1/2 size
|
||||
SourceImage fed(type, 24, 20);
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
ASSERT_EQ(fed.scale(), 2);
|
||||
|
||||
SourceImage stepped(type, 24, 20);
|
||||
MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS));
|
||||
int steps;
|
||||
ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE);
|
||||
ASSERT_EQ(stepped.scale(), 2);
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed)) << "image type " << type;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(JpegSourceDecode, WaitsForDataThatHasNotArrived) {
|
||||
SourceImage fed;
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
|
||||
@@ -3,44 +3,8 @@ 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
@@ -1,11 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The gtest binary has no YAML switch to emit USE_SWITCH_STORAGE, so define it here.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
cg.add_define("USE_SWITCH_STORAGE")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -21,13 +21,6 @@ switch:
|
||||
- logger.log: "Switch is now ON"
|
||||
on_turn_off:
|
||||
- logger.log: "Switch is now OFF"
|
||||
- platform: template
|
||||
name: "Flash Storage Switch"
|
||||
id: flash_storage_switch
|
||||
optimistic: true
|
||||
restore_state:
|
||||
state: KEEP
|
||||
storage: flash
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#ifdef USE_HOST
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include "esphome/components/host/preferences.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::switch_::testing {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
/// Switch is abstract; this is the minimum needed to instantiate one.
|
||||
class TestSwitch : public Switch {
|
||||
public:
|
||||
void write_state(bool state) override {}
|
||||
/// The preference key is derived from object_id_hash_; two instances with the same value share stored state.
|
||||
void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; }
|
||||
};
|
||||
|
||||
class SwitchRestoreStorageTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->temp_dir_ = fs::temp_directory_path() / "esphome_switch_storage_test";
|
||||
fs::remove_all(this->temp_dir_);
|
||||
fs::create_directories(this->temp_dir_);
|
||||
setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1);
|
||||
App.pre_setup("test_switch_storage", strlen("test_switch_storage"), "", 0);
|
||||
host::setup_preferences();
|
||||
// Host preferences also keep an in-memory store, which would leak between tests and GTEST_REPEAT iterations.
|
||||
host::host_preferences->reset();
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
unsetenv("ESPHOME_PREFDIR");
|
||||
fs::remove_all(this->temp_dir_);
|
||||
}
|
||||
|
||||
fs::path temp_dir_;
|
||||
};
|
||||
|
||||
// Host ignores flash vs RTC, so this checks that a switch with a storage bit in its restore mode still binds,
|
||||
// saves and restores its preference; which memory it lands in is checked by the ESP32/ESP8266 compile tests.
|
||||
TEST_F(SwitchRestoreStorageTest, SwitchWithAStorageChoiceStillRestoresItsState) {
|
||||
static constexpr uint32_t ID = 0x5001;
|
||||
const auto mode = static_cast<SwitchRestoreMode>(SWITCH_RESTORE_DEFAULT_OFF | RESTORE_MODE_STORAGE_RTC_MASK);
|
||||
{
|
||||
TestSwitch sw;
|
||||
sw.set_test_id(ID);
|
||||
sw.set_restore_mode(mode);
|
||||
auto first = sw.get_initial_state_with_restore_mode();
|
||||
ASSERT_TRUE(first.has_value());
|
||||
EXPECT_FALSE(first.value()) << "nothing stored yet, so RESTORE_DEFAULT_OFF gives OFF";
|
||||
sw.publish_state(true);
|
||||
global_preferences->sync();
|
||||
}
|
||||
TestSwitch sw2;
|
||||
sw2.set_test_id(ID);
|
||||
sw2.set_restore_mode(mode);
|
||||
auto restored = sw2.get_initial_state_with_restore_mode();
|
||||
ASSERT_TRUE(restored.has_value());
|
||||
EXPECT_TRUE(restored.value()) << "the saved state was not restored";
|
||||
}
|
||||
|
||||
} // namespace esphome::switch_::testing
|
||||
#endif // USE_HOST
|
||||
@@ -1,49 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
namespace esphome::switch_::testing {
|
||||
|
||||
class RecordingSwitch : public Switch {
|
||||
public:
|
||||
std::vector<bool> writes;
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override {
|
||||
this->writes.push_back(state);
|
||||
this->publish_state(state);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(SwitchRestore, DrivesTheRestoreModeStateThroughInversion) {
|
||||
RecordingSwitch on, off, inverted;
|
||||
on.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
off.set_restore_mode(SWITCH_ALWAYS_OFF);
|
||||
inverted.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
inverted.set_inverted(true);
|
||||
on.restore();
|
||||
off.restore();
|
||||
inverted.restore();
|
||||
EXPECT_EQ(on.writes, std::vector<bool>{true});
|
||||
EXPECT_EQ(off.writes, std::vector<bool>{false});
|
||||
EXPECT_EQ(inverted.writes, std::vector<bool>{false}); // the hardware level
|
||||
EXPECT_TRUE(on.state);
|
||||
EXPECT_FALSE(off.state);
|
||||
EXPECT_TRUE(inverted.state);
|
||||
}
|
||||
|
||||
TEST(SwitchRestore, DefaultAppliesOnlyWhenTheModeIsDisabled) {
|
||||
RecordingSwitch disabled, on;
|
||||
disabled.set_restore_mode(SWITCH_RESTORE_DISABLED);
|
||||
on.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
disabled.restore();
|
||||
EXPECT_TRUE(disabled.writes.empty());
|
||||
disabled.restore(true);
|
||||
on.restore(false);
|
||||
EXPECT_EQ(disabled.writes, std::vector<bool>{true});
|
||||
EXPECT_EQ(on.writes, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
} // namespace esphome::switch_::testing
|
||||
@@ -1,8 +0,0 @@
|
||||
switch:
|
||||
- platform: template
|
||||
name: "RTC Storage Switch"
|
||||
id: rtc_storage_switch
|
||||
optimistic: true
|
||||
initial_state: ON
|
||||
restore_state:
|
||||
storage: rtc
|
||||
@@ -1,3 +1,2 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rtc_storage: !include rtc_storage.yaml
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rtc_storage: !include rtc_storage.yaml
|
||||
|
||||
@@ -100,14 +100,6 @@ 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
|
||||
@@ -125,7 +117,3 @@ text_sensor:
|
||||
name: optarif
|
||||
tag_name: OPTARIF
|
||||
teleinfo_id: test_teleinfo
|
||||
- platform: teleinfo
|
||||
name: ltarf
|
||||
tag_name: LTARF
|
||||
teleinfo_id: test_teleinfo
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
#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
|
||||
+40
-38
@@ -58,30 +58,8 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
|
||||
- Provides debug logging to track state reception (use `--log-cli-level=DEBUG`)
|
||||
- Safe for concurrent use with multiple entity types
|
||||
|
||||
#### Subscribing after the initial states (`state_utils.py`)
|
||||
|
||||
`subscribe_after_initial_states()` does the `InitialStateHelper` setup in one call: it subscribes, waits
|
||||
for every entity's connect time state, and fails the test with the entities still missing on timeout.
|
||||
It returns a `StateSubscription` with `initial_states` (by key) and a `waiter`, a `StateWaiter` that sees
|
||||
every later state. `waiter.next_state(key=..., state_type=...)` arms a wait for the next matching state;
|
||||
call it before sending the command. `press_and_wait()` presses a button and returns the next state of a
|
||||
type, from one entity when `key=` is given:
|
||||
|
||||
```python
|
||||
from .state_utils import press_and_wait, subscribe_after_initial_states
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
|
||||
state = await press_and_wait(client, entities, sub.waiter, "set_position", CoverState)
|
||||
assert state.position == pytest.approx(0.5, abs=0.01)
|
||||
|
||||
next_light = sub.waiter.next_state(key=light.key, state_type=LightState)
|
||||
client.light_command(key=light.key, state=True)
|
||||
assert (await next_light).state is True
|
||||
```
|
||||
|
||||
Pass `on_state=` as well when the test also needs to collect every state.
|
||||
**Future work:**
|
||||
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
|
||||
|
||||
#### Entity Lookup Helpers (`state_utils.py`)
|
||||
|
||||
@@ -225,24 +203,47 @@ async def test_my_sensor(
|
||||
|
||||
##### State Subscription Pattern
|
||||
|
||||
**Recommended: Using subscribe_after_initial_states**
|
||||
**Recommended: Using InitialStateHelper**
|
||||
|
||||
When an API client connects, ESPHome automatically sends the current state of all entities.
|
||||
`subscribe_after_initial_states()` (from `state_utils.py`) swallows these initial states and only forwards
|
||||
subsequent state changes, to your callback and to the returned `waiter`:
|
||||
When an API client connects, ESPHome automatically sends the current state of all entities. The `InitialStateHelper` (from `state_utils.py`) handles this by swallowing these initial states and only forwarding subsequent state changes to your test callback:
|
||||
|
||||
```python
|
||||
from .state_utils import subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper
|
||||
|
||||
# Track state changes with futures
|
||||
loop = asyncio.get_running_loop()
|
||||
states: dict[int, EntityState] = {}
|
||||
state_future: asyncio.Future[EntityState] = loop.create_future()
|
||||
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""This callback only receives NEW state changes, not initial states."""
|
||||
states[state.key] = state
|
||||
# Check for specific condition using isinstance
|
||||
if isinstance(state, SensorState) and state.state == expected_value:
|
||||
if not state_future.done():
|
||||
state_future.set_result(state)
|
||||
|
||||
|
||||
# Get entities and set up state synchronization
|
||||
entities, services = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Arm the wait before the action that produces the state
|
||||
expected = sub.waiter.expect(
|
||||
lambda s: isinstance(s, SensorState) and s.state == expected_value
|
||||
)
|
||||
# Subscribe with the wrapper that filters initial states
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states to be broadcast; on timeout this fails the test
|
||||
# and lists the entities that never sent one
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Now perform your test actions - on_state will only receive new changes
|
||||
# ... trigger state changes ...
|
||||
result = await expected
|
||||
|
||||
# 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())}")
|
||||
```
|
||||
|
||||
**Legacy: Manual State Tracking**
|
||||
@@ -267,9 +268,10 @@ def on_state(state: EntityState) -> None:
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for state with timeout
|
||||
result = await wait_or_fail(
|
||||
state_future, 5.0, "Expected state not received", states=states
|
||||
)
|
||||
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())}")
|
||||
```
|
||||
|
||||
##### Service Execution Pattern
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
@@ -101,22 +100,6 @@ class StateWaiter:
|
||||
self._waiters.append(entry)
|
||||
return self._wait(entry, timeout, label)
|
||||
|
||||
def next_state[T: EntityState](
|
||||
self,
|
||||
key: int | None = None,
|
||||
state_type: type[T] = EntityState,
|
||||
timeout: float = 5.0,
|
||||
) -> Awaitable[T]:
|
||||
"""Arm a wait for the next ``state_type`` state, for entity ``key`` if given.
|
||||
|
||||
Like :meth:`expect`, call it before sending the command and await the result afterwards.
|
||||
"""
|
||||
return self.expect(
|
||||
lambda s: isinstance(s, state_type) and (key is None or s.key == key),
|
||||
timeout,
|
||||
state_type.__name__ if key is None else f"{state_type.__name__} key={key}",
|
||||
)
|
||||
|
||||
async def _wait(
|
||||
self,
|
||||
entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]],
|
||||
@@ -419,58 +402,6 @@ class InitialStateHelper:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateSubscription:
|
||||
"""A state subscription that is past the connect time states."""
|
||||
|
||||
initial_states: dict[int, EntityState]
|
||||
waiter: StateWaiter
|
||||
|
||||
|
||||
async def subscribe_after_initial_states(
|
||||
client: APIClient,
|
||||
entities: list[EntityInfo],
|
||||
on_state: Callable[[EntityState], None] | None = None,
|
||||
timeout: float = 5.0,
|
||||
) -> StateSubscription:
|
||||
"""Subscribe to states and wait until every entity has sent its connect time state.
|
||||
|
||||
Later states go to ``on_state`` (if given) and to the returned ``waiter``. The connect time
|
||||
states are kept in ``initial_states`` by key. Fails the test listing the entities still missing.
|
||||
"""
|
||||
helper = InitialStateHelper(entities)
|
||||
waiter = StateWaiter()
|
||||
|
||||
def forward(state: EntityState) -> None:
|
||||
waiter.on_state(state)
|
||||
if on_state is not None:
|
||||
on_state(state)
|
||||
|
||||
client.subscribe_states(helper.on_state_wrapper(forward))
|
||||
await helper.wait_for_initial_states(timeout)
|
||||
return StateSubscription(helper.initial_states, waiter)
|
||||
|
||||
|
||||
async def press_and_wait[T: EntityState](
|
||||
client: APIClient,
|
||||
entities: list[EntityInfo],
|
||||
waiter: StateWaiter,
|
||||
button: str,
|
||||
state_type: type[T],
|
||||
*,
|
||||
key: int | None = None,
|
||||
timeout: float = 5.0,
|
||||
) -> T:
|
||||
"""Press the button whose object_id contains ``button`` and return the next ``state_type`` state.
|
||||
|
||||
Pass the entity's ``key`` when the fixture has more than one entity of that type.
|
||||
"""
|
||||
btn = require_entity(entities, button, ButtonInfo)
|
||||
next_state = waiter.next_state(key=key, state_type=state_type, timeout=timeout)
|
||||
client.button_command(btn.key)
|
||||
return await next_state
|
||||
|
||||
|
||||
class SensorStateCollector:
|
||||
"""Collects sensor, binary sensor, and text sensor state updates with wait helpers.
|
||||
|
||||
|
||||
@@ -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,41 +23,54 @@ 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
|
||||
"""
|
||||
waiter = LineWaiter()
|
||||
test_complete = asyncio.Event()
|
||||
expected = {0, 1, 2, 3, 4}
|
||||
|
||||
def captures(pattern: str) -> list[tuple[int, ...]]:
|
||||
return [
|
||||
tuple(map(int, m.groups()))
|
||||
for line in waiter.lines
|
||||
if (m := re.search(pattern, line))
|
||||
]
|
||||
# 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 arg_list(pattern: str) -> list[int]:
|
||||
return [arg for (arg,) in captures(pattern)]
|
||||
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()
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
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(
|
||||
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)
|
||||
await wait_or_fail(test_complete, 8.0, "test timed out")
|
||||
|
||||
# order may change, but all args must be present
|
||||
for args in in_repeat_args.values():
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -17,10 +19,20 @@ async def test_api_action_metadata(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Action and argument metadata reach the client and the actions still run."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
_, services = await client.list_entities_services()
|
||||
@@ -60,5 +72,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 waiter.wait_for("Buzzer: two_short", timeout=5.0)
|
||||
await waiter.wait_for("Plain action called", timeout=5.0)
|
||||
await asyncio.wait_for(buzzer_called, timeout=5.0)
|
||||
await asyncio.wait_for(plain_called, timeout=5.0)
|
||||
|
||||
@@ -5,12 +5,13 @@ 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
|
||||
|
||||
@@ -22,11 +23,50 @@ async def test_api_action_responses(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test API action response modes work correctly."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
@@ -106,9 +146,7 @@ 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 waiter.wait_for(
|
||||
"ACTION_NO_RESPONSE called with: test_message", timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(no_response_future, timeout=5.0)
|
||||
|
||||
# Test action_status_response with success (supports_response: status)
|
||||
response = await client.execute_service(
|
||||
@@ -116,7 +154,7 @@ async def test_api_action_responses(
|
||||
{"should_succeed": True},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for("ACTION_STATUS_RESPONSE success (call_id=", timeout=5.0)
|
||||
await asyncio.wait_for(status_success_future, 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}"
|
||||
@@ -131,7 +169,7 @@ async def test_api_action_responses(
|
||||
{"should_succeed": False},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for("ACTION_STATUS_RESPONSE error (call_id=", timeout=5.0)
|
||||
await asyncio.wait_for(status_error_future, 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}"
|
||||
@@ -147,9 +185,7 @@ async def test_api_action_responses(
|
||||
{"value": 42},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"ACTION_OPTIONAL_RESPONSE (call_id=", "value=42)", timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(optional_response_future, 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}"
|
||||
@@ -169,9 +205,7 @@ async def test_api_action_responses(
|
||||
{"name": "World"},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"ACTION_ONLY_RESPONSE (call_id=", "name=World)", timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(only_response_future, 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}"
|
||||
@@ -190,7 +224,7 @@ async def test_api_action_responses(
|
||||
{},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for("ACTION_NESTED_JSON (call_id=", timeout=5.0)
|
||||
await asyncio.wait_for(nested_json_future, 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,11 +30,52 @@ 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
|
||||
"""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
@@ -57,7 +98,7 @@ async def test_api_action_timeout(
|
||||
{},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for("ACTION_IMMEDIATE responding", timeout=1.0)
|
||||
await asyncio.wait_for(immediate_future, timeout=1.0)
|
||||
assert response is not None, "Expected response for immediate action"
|
||||
assert response.success is True
|
||||
|
||||
@@ -67,7 +108,7 @@ async def test_api_action_timeout(
|
||||
{},
|
||||
return_response=True,
|
||||
)
|
||||
await waiter.wait_for("ACTION_SHORT_DELAY responding", timeout=1.0)
|
||||
await asyncio.wait_for(short_delay_responding_future, timeout=1.0)
|
||||
assert response is not None, "Expected response for short delay action"
|
||||
assert response.success is True
|
||||
|
||||
@@ -87,18 +128,13 @@ async def test_api_action_timeout(
|
||||
)
|
||||
|
||||
# Wait for the action to start
|
||||
await waiter.wait_for("ACTION_LONG_DELAY starting", timeout=1.0)
|
||||
await asyncio.wait_for(long_delay_starting_future, timeout=1.0)
|
||||
|
||||
# Wait for the action to try to respond (after 1s delay)
|
||||
await waiter.wait_for(
|
||||
"ACTION_LONG_DELAY responding (after timeout)", timeout=2.0
|
||||
)
|
||||
await asyncio.wait_for(long_delay_responding_future, timeout=2.0)
|
||||
|
||||
# Wait for the warning log about no active call
|
||||
await waiter.wait_for(
|
||||
"Cannot send response: no active call found for action_call_id",
|
||||
timeout=1.0,
|
||||
)
|
||||
await asyncio.wait_for(timeout_warning_future, 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
|
||||
@@ -107,6 +143,12 @@ 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,10 +2,12 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -17,17 +19,45 @@ async def test_api_conditional_memory(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test API triggers and services work correctly with conditional compilation."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run with log monitoring
|
||||
async with run_compiled(yaml_config, line_callback=waiter.callback):
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
async with api_client_connected() as client:
|
||||
# Verify device info
|
||||
await assert_device_name(client, "api-conditional-memory-test")
|
||||
|
||||
# Wait for connection log
|
||||
# The leading space keeps "disconnected from" from matching
|
||||
await waiter.wait_for("Client", " connected from", timeout=5.0)
|
||||
await asyncio.wait_for(connected_future, timeout=5.0)
|
||||
|
||||
# List services
|
||||
_, services = await client.list_entities_services()
|
||||
@@ -55,7 +85,7 @@ async def test_api_conditional_memory(
|
||||
await client.execute_service(simple_service, {})
|
||||
|
||||
# Wait for service log
|
||||
await waiter.wait_for("Simple service called", timeout=5.0)
|
||||
await asyncio.wait_for(service_simple_future, timeout=5.0)
|
||||
|
||||
# Call service with arguments
|
||||
await client.execute_service(
|
||||
@@ -69,13 +99,11 @@ async def test_api_conditional_memory(
|
||||
)
|
||||
|
||||
# Wait for service with args log
|
||||
await waiter.wait_for(
|
||||
"Service called with: test_string, 123, 1, 42.50", timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(service_args_future, timeout=5.0)
|
||||
|
||||
# Client disconnected here, wait for disconnect log
|
||||
await waiter.wait_for("Client", "disconnected from", timeout=5.0)
|
||||
await asyncio.wait_for(disconnected_future, timeout=5.0)
|
||||
|
||||
# Verify fix for issue #11131: api.connected should be false in trigger
|
||||
# when the last client disconnects
|
||||
await waiter.wait_for("No clients remaining", timeout=5.0)
|
||||
await asyncio.wait_for(no_clients_future, timeout=5.0)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -17,11 +19,52 @@ async def test_api_custom_services(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test CustomAPIDevice services work correctly with custom_services: true."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
@@ -45,7 +88,7 @@ async def test_api_custom_services(
|
||||
|
||||
# Test YAML service
|
||||
await client.execute_service(yaml_service, {})
|
||||
await waiter.wait_for("YAML service called", timeout=5.0)
|
||||
await asyncio.wait_for(yaml_service_future, timeout=5.0)
|
||||
|
||||
# Verify YAML service with args arguments
|
||||
assert len(yaml_args_service.args) == 2
|
||||
@@ -61,7 +104,7 @@ async def test_api_custom_services(
|
||||
"my_string": "test_value",
|
||||
},
|
||||
)
|
||||
await waiter.wait_for("YAML service with args: 123, test_value", timeout=5.0)
|
||||
await asyncio.wait_for(yaml_args_future, timeout=5.0)
|
||||
|
||||
# Verify YAML service with many args arguments
|
||||
assert len(yaml_many_args_service.args) == 4
|
||||
@@ -83,11 +126,11 @@ async def test_api_custom_services(
|
||||
"arg4": "hello",
|
||||
},
|
||||
)
|
||||
await waiter.wait_for("YAML service many args: 42, 3.14, 1, hello", timeout=5.0)
|
||||
await asyncio.wait_for(yaml_many_args_future, timeout=5.0)
|
||||
|
||||
# Test simple CustomAPIDevice service
|
||||
await client.execute_service(custom_service, {})
|
||||
await waiter.wait_for("Custom test service called!", timeout=5.0)
|
||||
await asyncio.wait_for(custom_service_future, timeout=5.0)
|
||||
|
||||
# Verify custom_args_service arguments
|
||||
assert len(custom_args_service.args) == 4
|
||||
@@ -107,9 +150,7 @@ async def test_api_custom_services(
|
||||
"arg_float": 78.9,
|
||||
},
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"Custom service called with: test_string, 456, 1, 78.90", timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(custom_args_future, timeout=5.0)
|
||||
|
||||
# Verify array service arguments
|
||||
assert len(custom_arrays_service.args) == 4
|
||||
@@ -129,15 +170,9 @@ async def test_api_custom_services(
|
||||
"string_array": ["hello", "world"],
|
||||
},
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"Array service called with 2 bools, 3 ints, 2 floats, 2 strings",
|
||||
timeout=5.0,
|
||||
)
|
||||
await asyncio.wait_for(custom_arrays_future, 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 waiter.wait_for(
|
||||
"This subscription uses std::string API for backward compatibility",
|
||||
timeout=5.0,
|
||||
)
|
||||
await asyncio.wait_for(ha_state_future, timeout=5.0)
|
||||
|
||||
@@ -23,7 +23,6 @@ from .state_utils import (
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -252,191 +251,173 @@ async def test_api_homeassistant(
|
||||
await client.execute_service(trigger_service, {})
|
||||
|
||||
# Wait for all tests to complete with appropriate timeouts
|
||||
# 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}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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
|
||||
humidity_line = await asyncio.wait_for(
|
||||
ha_humidity_state_future, timeout=5.0
|
||||
)
|
||||
assert "Current HA Humidity: 65.0" in humidity_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
|
||||
motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0)
|
||||
assert "Current HA Motion: ON" in motion_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
|
||||
weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0)
|
||||
assert "Current HA Weather: sunny" in weather_line
|
||||
|
||||
# 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}"
|
||||
# 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}"
|
||||
|
||||
# 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."
|
||||
)
|
||||
# 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."
|
||||
)
|
||||
|
||||
# Wait for completion
|
||||
await wait_or_fail(tests_complete_future, 5.0, "tests did not complete", **ctx)
|
||||
# Wait for completion
|
||||
await asyncio.wait_for(tests_complete_future, timeout=5.0)
|
||||
|
||||
# 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']}"
|
||||
)
|
||||
# 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']}"
|
||||
)
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
# 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"] == ""
|
||||
# 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"] == ""
|
||||
)
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
# 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"]
|
||||
# 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"]
|
||||
|
||||
# 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())
|
||||
# 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())
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
# 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}"
|
||||
)
|
||||
# 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}"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
|
||||
@@ -26,9 +25,26 @@ async def test_api_homeassistant_action_no_subscriber(
|
||||
) -> None:
|
||||
"""Undeliverable actions warn in the log; actions after subscribing arrive."""
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter = LineWaiter()
|
||||
|
||||
boot_warning_future = loop.create_future()
|
||||
connected_warning_future = loop.create_future()
|
||||
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:
|
||||
@@ -39,23 +55,16 @@ async def test_api_homeassistant_action_no_subscriber(
|
||||
):
|
||||
button_action_future.set_result(service_call)
|
||||
|
||||
async with run_compiled(yaml_config, line_callback=waiter.callback):
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
# The on_boot action fires with no client connected at all.
|
||||
await waiter.wait_for(
|
||||
"Home Assistant action 'test.boot_action' dropped; no client connected",
|
||||
timeout=10.0,
|
||||
)
|
||||
await asyncio.wait_for(boot_warning_future, 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 waiter.wait_for(
|
||||
"Home Assistant action 'test.connected_action' dropped; "
|
||||
"client has not subscribed to actions (yet)",
|
||||
timeout=5.0,
|
||||
)
|
||||
await asyncio.wait_for(connected_warning_future, timeout=5.0)
|
||||
|
||||
# After subscribing, actions must be delivered normally (and the
|
||||
# dropped ones must not suddenly show up).
|
||||
|
||||
@@ -23,7 +23,6 @@ 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 = {
|
||||
@@ -139,12 +138,10 @@ async def light_entity(
|
||||
client.subscribe_service_calls(calls.put_nowait)
|
||||
client.subscribe_states(on_state)
|
||||
client.subscribe_home_assistant_states(on_state_sub)
|
||||
await wait_or_fail(
|
||||
all_subscribed,
|
||||
5.0,
|
||||
"never subscribed",
|
||||
missing=lambda: SUBSCRIPTIONS - subscribed,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(all_subscribed, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}")
|
||||
ha = FakeHomeAssistant(client, key, services, waiter, calls, states)
|
||||
ha.report("supported_color_modes", modes)
|
||||
ha.report("min_color_temp_kelvin", "2000")
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -72,10 +71,15 @@ async def test_api_list_entities_backpressure(
|
||||
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
|
||||
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
|
||||
|
||||
waiter = LineWaiter()
|
||||
async with run_compiled(config, line_callback=waiter.callback):
|
||||
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):
|
||||
# Fails loudly if the pin never applied
|
||||
await waiter.wait_for(SNDBUF_PIN_LOG, timeout=10)
|
||||
await asyncio.wait_for(pin_applied.wait(), 10)
|
||||
|
||||
async with RawApiClient(
|
||||
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -129,13 +128,15 @@ async def test_api_message_size_batching(
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for states with timeout
|
||||
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)}",
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
# Verify we received states from all entities
|
||||
assert expected_keys.issubset(received_keys)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""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
|
||||
|
||||
@@ -13,15 +15,21 @@ async def test_api_reboot_timeout(
|
||||
run_compiled: RunCompiledFunction,
|
||||
) -> None:
|
||||
"""Test that the device reboots when no API clients connect within the timeout."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run the device without connecting any API client
|
||||
async with run_compiled(yaml_config, line_callback=waiter.callback):
|
||||
# 0.5s reboot timeout + some margin for processing
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
# Wait for reboot with timeout
|
||||
# (0.5s reboot timeout + some margin for processing)
|
||||
await wait_or_fail(
|
||||
waiter.wait_for("No clients; rebooting"),
|
||||
2.0,
|
||||
"Device did not reboot within expected timeout",
|
||||
reboot_future, 2.0, "Device did not reboot within expected timeout"
|
||||
)
|
||||
|
||||
# Test passes if we get here - reboot was detected
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
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
|
||||
@@ -17,11 +19,34 @@ async def test_api_string_lambda(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test Home Assistant action lambdas that return different types are sent as strings."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
@@ -49,16 +74,15 @@ async def test_api_string_lambda(
|
||||
char_ptr_service, {"input_number": 123, "input_string": "test_string"}
|
||||
)
|
||||
|
||||
# Confirms the lambdas compiled and executed
|
||||
# Wait for all service log messages
|
||||
# This confirms the lambdas compiled successfully and executed
|
||||
await wait_or_fail(
|
||||
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,
|
||||
asyncio.gather(
|
||||
string_called_future,
|
||||
int_called_future,
|
||||
float_called_future,
|
||||
char_ptr_called_future,
|
||||
),
|
||||
5.0,
|
||||
"Not every lambda service logged its call",
|
||||
logged=waiter.lines,
|
||||
"One or more service log messages not received - lambda may have failed to compile or execute",
|
||||
)
|
||||
|
||||
@@ -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,24 +23,25 @@ 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."""
|
||||
waiter = LineWaiter()
|
||||
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)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
assert await client.device_info() is not None
|
||||
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))
|
||||
]
|
||||
await asyncio.wait_for(complete.wait(), timeout=10.0)
|
||||
|
||||
assert injected_delay_total == 270, (
|
||||
f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms"
|
||||
|
||||
@@ -7,7 +7,6 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -18,76 +17,97 @@ async def test_branch_resume_async(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A branch that ends after a delay must resume its owner exactly once."""
|
||||
waiter = LineWaiter()
|
||||
lines: list[str] = []
|
||||
waiters: list[tuple[str, asyncio.Future[None]]] = []
|
||||
|
||||
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 waiter.lines)
|
||||
return sum(has_marker(text, line) for line in lines)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
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 waiter.wait_for("after-if", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("after-else", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("after-while", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("stop-check-done", timeout=3.0)
|
||||
await asyncio.wait_for(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 waiter.wait_for("later-check-done", timeout=2.0)
|
||||
await asyncio.wait_for(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 waiter.wait_for("after-empty-while", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("after-nested-while", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("stop-while-done", timeout=3.0)
|
||||
await asyncio.wait_for(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 waiter.wait_for("queued-after-if-3", timeout=2.0)
|
||||
queued = [line for line in waiter.lines if "queued-" in line]
|
||||
await asyncio.wait_for(done, timeout=2.0)
|
||||
queued = [line for line in lines if "queued-" in line]
|
||||
order = [
|
||||
"queued-branch-1",
|
||||
"queued-after-if-1",
|
||||
@@ -98,29 +118,33 @@ 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 waiter.wait_for("restart-after-if-2", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("after-delay-last", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("parallel-after-if-3", timeout=2.0)
|
||||
await asyncio.wait_for(done, 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 waiter.wait_for("stop-in-conditions-done", timeout=2.0)
|
||||
await asyncio.wait_for(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,11 +2,13 @@
|
||||
|
||||
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
|
||||
@@ -16,30 +18,71 @@ async def test_crc8_helper(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test the CRC8 helper function through integration testing."""
|
||||
waiter = LineWaiter()
|
||||
# Track test completion with asyncio.Event
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
def passed(name: str) -> bool:
|
||||
return any("ALL TESTS PASSED" in line and name in line for line in waiter.lines)
|
||||
# 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}")
|
||||
|
||||
# Compile and run the test
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
await assert_device_name(client, "crc8-helper-test")
|
||||
|
||||
# Wait for tests to complete with timeout
|
||||
await waiter.wait_for("CRC8 Integration Test Complete", timeout=5.0)
|
||||
await wait_or_fail(
|
||||
test_complete, 5.0, "CRC8 integration test timed out after 5 seconds"
|
||||
)
|
||||
|
||||
# Verify all tests passed
|
||||
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"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ 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 = {
|
||||
@@ -144,17 +143,20 @@ async def test_device_id_in_state(
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Wait for states
|
||||
await wait_or_fail(
|
||||
states_future,
|
||||
10.0,
|
||||
"Did not receive all entity states",
|
||||
received=lambda: len(states),
|
||||
missing=lambda: (
|
||||
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 = (
|
||||
(set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES)
|
||||
- {e.name for e in all_entities if e.key in states}
|
||||
- received_names
|
||||
- {"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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user