Merge remote-tracking branch 'origin/shelly-dimmer-update-test' into shelly_dimmer-tables-progmem

This commit is contained in:
J. Nick Koston
2026-10-13 12:31:53 -10:00
12 changed files with 196 additions and 44 deletions
+3 -1
View File
@@ -330,7 +330,9 @@ async def write_image(
if byte_order is not None:
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
pixels = encoder.convert(image.resize((width, height)), path).getdata()
pixels = encoder.convert(
image.resize((width, height)), path
).get_flattened_data()
for row in range(height):
for col in range(width):
encoder.encode(pixels[row * width + col])
+1 -1
View File
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list(
async def to_code(config: list[ConfigType]) -> None:
cg.add_library("wjtje/qr-code-generator-library", "^1.7.0")
cg.add_library("wjtje/qr-code-generator-library", "1.7.0")
for entry in config:
var = cg.new_Pvariable(entry[CONF_ID])
+1 -1
View File
@@ -115,7 +115,7 @@ class PNGFormat(Format):
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_PNG")
cg.add_library("pngle", "1.1.0")
cg.add_library("kikuchan98/pngle", "1.1.0")
class QOIFormat(Format):
+3 -2
View File
@@ -2,12 +2,13 @@
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/time.h"
namespace esphome::syslog {
// Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7)
constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = {
static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = {
3, // NONE
3, // ERROR
4, // WARN
@@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
// Syslog PRI calculation: facility * 8 + severity
int severity = 7;
if ((unsigned) level <= 7) {
severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level];
severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]);
}
int pri = this->facility_ * 8 + severity;
+11 -6
View File
@@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType:
CONF_SUPPLEMENTAL_HEATING_ACTION,
CONF_SUPPLEMENTAL_HEATING_DELTA,
],
CONF_MIN_COOLING_OFF_TIME: [
CONF_COOL_ACTION,
],
CONF_MIN_COOLING_RUN_TIME: [
CONF_COOL_ACTION,
],
CONF_MIN_FANNING_OFF_TIME: [
CONF_FAN_ONLY_ACTION,
],
@@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType:
f"{req_trigger} must be defined to use {config_trigger}"
)
# Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action.
for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME):
if (
timer in config
and CONF_COOL_ACTION not in config
and CONF_DRY_ACTION not in config
):
raise cv.Invalid(
f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}"
)
if CONF_FAN_ONLY_ACTION in config:
# determine validation requirements based on fan_only_action_uses_fan_mode_timer setting
if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True:
+22 -28
View File
@@ -3,8 +3,6 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <vector>
namespace esphome::toshiba {
struct RacPt1411hwruFanSpeed {
@@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72;
const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E;
// RAC-PT1411HWRU Swing
const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9;
const std::vector<uint8_t> RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB};
const std::vector<uint8_t> RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA};
static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB};
static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA};
// RAC-PT1411HWRU Fan speeds
const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B;
constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66};
@@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E;
// Hence these look-up tables. In addition, the upper nibble is used here for additional
// "negative" and "fractional value" flags as required for some temperatures.
// RAC-PT1411HWRU °C Temperatures (short codes)
const std::vector<uint8_t> RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05,
0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B};
static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05,
0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B};
// RAC-PT1411HWRU °F Temperatures (short codes)
const std::vector<uint8_t> RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02,
0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C,
0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B};
static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = {
0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05,
0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B};
// RAS-2819T protocol constants
const uint16_t RAS_2819T_HEADER1 = 0xC23D;
@@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09;
static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame
// RAS-2819T temperature codes for 18-30°C
static const uint8_t RAS_2819T_TEMP_CODES[] = {
static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = {
0x10, // 18°C
0x30, // 19°C
0x20, // 20°C
@@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) {
return 0x40; // Default to 24°C
}
return RAS_2819T_TEMP_CODES[temp_index];
return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]);
}
/**
@@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) {
// Find the code in the temperature array
for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) {
if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) {
if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) {
return static_cast<float>(temp_index + 18); // 18°C is the minimum
}
}
@@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() {
index = static_cast<uint8_t>(roundf(temp_adjd));
if (this->model_ == MODEL_RAC_PT1411HWRU_F) {
code = RAC_PT1411HWRU_TEMPERATURE_F[index];
code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]);
message[9] |= RAC_PT1411HWRU_FLAG_FAH;
} else {
code = RAC_PT1411HWRU_TEMPERATURE_C[index];
code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]);
}
if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) {
code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY;
@@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() {
// Swing Mode
data->reset();
data->space(TOSHIBA_PACKET_SPACE);
switch (this->swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1);
break;
case climate::CLIMATE_SWING_OFF:
default:
this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1);
}
const uint8_t *swing_frame =
this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF;
uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH];
progmem_memcpy(swing, swing_frame, sizeof(swing));
this->encode_(data, swing, sizeof(swing), 1);
data->space(TOSHIBA_PACKET_SPACE);
transmit.perform();
@@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
temperature_code =
(message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG);
if (message[15] & RAC_PT1411HWRU_FLAG_FAH) {
for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) {
if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) {
for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) {
if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) {
this->target_temperature = static_cast<float>((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9;
}
}
} else {
for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) {
if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) {
for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) {
if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) {
this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN;
}
}
@@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
break;
// Swing mode
case RAC_PT1411HWRU_SWING_HEADER:
if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) {
if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
+2
View File
@@ -21,6 +21,8 @@ dependencies:
version: "1.8.2"
espressif/esp-tflite-micro:
version: 1.3.3~1
espressif/esp-nn:
version: 1.1.2
espressif/esp32-camera:
version: 2.1.7
espressif/mdns:
+5 -5
View File
@@ -46,9 +46,9 @@ lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
improv/Improv@1.2.7 ; improv_serial / improv_ble
kikuchan98/pngle@1.1.0 ; online_image
kikuchan98/pngle@1.1.0 ; runtime_image
; Using the repository directly, otherwise ESP-IDF can't use the library
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image
; This dependency is used only in unit tests.
; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py
; See scripts/cpp_unit_test.py and tests/components/README.md
@@ -82,7 +82,7 @@ lib_deps =
esphome/noise-c@0.1.31 ; noise (api, ota)
SPI ; spi (Arduino built-in)
Wire ; i2c (Arduino built-int)
heman/AsyncMqttClient-esphome@1.0.0 ; mqtt
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
freekode/TM1651@1.0.1 ; tm1651
tonia/HeatpumpIR@1.0.42 ; heatpumpir
build_flags =
@@ -252,8 +252,8 @@ lib_deps =
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
improv/Improv@1.2.7 ; improv_serial
kikuchan98/pngle@1.1.0 ; online_image
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image
kikuchan98/pngle@1.1.0 ; runtime_image
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image
build_flags =
${common:arduino.build_flags}
-DUSE_LIBRETINY
@@ -0,0 +1,27 @@
---
esphome:
name: test
logger:
esp32:
board: esp32dev
sensor:
- platform: template
id: temp_sensor
lambda: return 21.0;
climate:
- platform: thermostat
id: heater
name: Heater
sensor: temp_sensor
min_idle_time: 30s
min_heating_off_time: 300s
min_heating_run_time: 300s
min_cooling_off_time: 300s
heat_action:
- logger.log: heat
idle_action:
- logger.log: idle
@@ -0,0 +1,30 @@
---
esphome:
name: test
logger:
esp32:
board: esp32dev
sensor:
- platform: template
id: temp_sensor
lambda: return 21.0;
climate:
- platform: thermostat
id: dehumidifier
name: Dehumidifier
sensor: temp_sensor
min_idle_time: 0s
min_cooling_off_time: 0s
min_cooling_run_time: 0s
min_fanning_off_time: 0s
min_fanning_run_time: 0s
idle_action:
- logger.log: idle
fan_only_action:
- logger.log: fan only
dry_action:
- logger.log: dry
@@ -0,0 +1,36 @@
"""Tests for the thermostat config validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_dry_only_config_is_valid(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers."""
main_cpp = generate_main(component_config_path("dry_only.yaml"))
assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp
assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp
assert "dehumidifier->set_supports_dry(true);" in main_cpp
assert "dehumidifier->set_supports_cool(" not in main_cpp
def test_cooling_timer_needs_cool_or_dry_action(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
"""A cooling timer with neither cool_action nor dry_action is still rejected."""
CORE.config_path = component_config_path("cooling_timer_without_action.yaml")
assert read_config({}) is None
captured = capsys.readouterr()
assert (
"cool_action or dry_action must be defined to use min_cooling_off_time"
in captured.out
)
@@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) {
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9);
}
// The RAC-PT1411HWRU sends its main message, then a separate swing packet
// wrapped in packet spaces; both use the generic timings.
static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector<uint8_t> &bytes) {
ASSERT_GE(actual.size(), 2u);
EXPECT_EQ(actual.front(), -10500);
EXPECT_EQ(actual.back(), -10500);
const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1);
expect_frame(frame, waveform(bytes, GENERIC), 6);
}
TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) {
Fixture f(MODEL_RAC_PT1411HWRU_C);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL);
expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB});
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF);
expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA});
}
// The temperature tables are read on the way out and searched on the way back
// in, so what one fixture sends must decode to the same state in another.
TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) {
for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) {
for (int temperature = 16; temperature <= 30; temperature++) {
SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature);
Fixture tx(model);
tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO);
// The main message is sent before the swing packet
const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2];
Fixture rx(model);
remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
ASSERT_TRUE(static_cast<remote_base::RemoteReceiverListener &>(rx.climate).on_receive(data));
EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f);
}
}
}
// The RAS-2819T table is read for the code on the way out and searched on the way back in.
TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) {
for (int temperature = 18; temperature <= 30; temperature++) {
SCOPED_TRACE(::testing::Message() << "temperature " << temperature);
Fixture tx(MODEL_RAS_2819T);
tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO);
Fixture rx(MODEL_RAS_2819T);
remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
ASSERT_TRUE(static_cast<remote_base::RemoteReceiverListener &>(rx.climate).on_receive(data));
EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature);
}
}
static bool receive(ToshibaClimate &climate, const std::vector<uint8_t> &bytes, const Timings &t) {
const remote_base::RawTimings raw = waveform(bytes, t);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);