mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 08:28:30 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-607
This commit is contained in:
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
|
||||
# and add the noise-c library dependency.
|
||||
# api must run its to_code to define USE_API and USE_API_NOISE. The
|
||||
# AUTO_LOADed noise component runs its own to_code via the override in
|
||||
# tests/benchmarks/components/noise/__init__.py.
|
||||
manifest.enable_codegen()
|
||||
|
||||
original_to_code = manifest.to_code
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# to_code must run: it defines USE_NOISE and adds the noise-c library
|
||||
# the api benchmark sources need.
|
||||
manifest.enable_codegen()
|
||||
@@ -13,17 +13,18 @@ namespace api {
|
||||
class APIConnection;
|
||||
} // namespace api
|
||||
|
||||
namespace uart {
|
||||
enum class UARTFlushResult : uint8_t {
|
||||
UART_FLUSH_RESULT_SUCCESS,
|
||||
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
|
||||
UART_FLUSH_RESULT_TIMEOUT,
|
||||
UART_FLUSH_RESULT_FAILED,
|
||||
};
|
||||
} // namespace uart
|
||||
|
||||
namespace serial_proxy {
|
||||
|
||||
enum class SerialProxyResult : uint8_t {
|
||||
SERIAL_PROXY_RESULT_OK,
|
||||
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS,
|
||||
SERIAL_PROXY_RESULT_PORT_IN_USE,
|
||||
SERIAL_PROXY_RESULT_INVALID_ARGUMENT,
|
||||
SERIAL_PROXY_RESULT_ERROR,
|
||||
SERIAL_PROXY_RESULT_TIMEOUT,
|
||||
SERIAL_PROXY_RESULT_NOT_SUPPORTED,
|
||||
};
|
||||
|
||||
class SerialProxy {
|
||||
public:
|
||||
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
|
||||
@@ -31,13 +32,20 @@ class SerialProxy {
|
||||
const char *get_name() const { return ""; }
|
||||
api::enums::SerialProxyPortType get_port_type() const { return {}; }
|
||||
api::APIConnection *get_api_connection() { return nullptr; }
|
||||
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
|
||||
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
|
||||
uint32_t stop_bits, uint32_t data_size) {}
|
||||
SerialProxyResult serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
|
||||
uint8_t stop_bits, uint8_t data_size) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
|
||||
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
|
||||
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
uint32_t get_modem_pins() const { return 0; }
|
||||
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
|
||||
uint32_t get_configured_modem_pins() const { return 0; }
|
||||
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
|
||||
|
||||
protected:
|
||||
uint32_t instance_index_{0};
|
||||
|
||||
@@ -15,7 +15,9 @@ namespace zwave_proxy {
|
||||
class ZWaveProxy {
|
||||
public:
|
||||
api::APIConnection *get_api_connection() { return nullptr; }
|
||||
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
|
||||
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {
|
||||
return api::enums::ZWAVE_PROXY_STATUS_OK;
|
||||
}
|
||||
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
|
||||
void api_connection_authenticated(api::APIConnection *conn) {}
|
||||
uint32_t get_feature_flags() const { return 0; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-7238
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -16,6 +16,7 @@ from esphome.core import EsphomeError
|
||||
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
|
||||
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
|
||||
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
|
||||
("test_bk7238.yaml", "BK7238.*bootloader"),
|
||||
],
|
||||
)
|
||||
def test_unsupported_family_rejected(
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""Tests for the deep sleep component."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import deep_sleep
|
||||
from esphome.const import CONF_WAKEUP_PIN, PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def test_deep_sleep_setup(generate_main):
|
||||
"""
|
||||
@@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main):
|
||||
" .gpio_cause = 30000,\n"
|
||||
"});"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error."""
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
config = {
|
||||
CONF_WAKEUP_PIN: [
|
||||
{"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
|
||||
],
|
||||
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not both"):
|
||||
deep_sleep.validate_config(config)
|
||||
|
||||
|
||||
def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry."""
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
config = {
|
||||
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}],
|
||||
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
|
||||
}
|
||||
result = deep_sleep.validate_config(config)
|
||||
|
||||
assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result
|
||||
assert (
|
||||
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for emontx sensor tag defaults."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import sensor
|
||||
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_STATE_CLASS,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_via_config_schema(tag: str) -> dict:
|
||||
"""Run a minimal config through the real CONFIG_SCHEMA pipeline, the
|
||||
same path a user's YAML goes through."""
|
||||
return CONFIG_SCHEMA(
|
||||
{"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"}
|
||||
)
|
||||
|
||||
|
||||
def test_config_schema_applies_tag_default_state_class():
|
||||
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
|
||||
default wins over apply_tag_defaults' per-prefix value, and E1 would
|
||||
resolve to measurement instead of total_increasing. Driving the real
|
||||
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
|
||||
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
|
||||
"""
|
||||
result = _resolve_via_config_schema("E1")
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
|
||||
STATE_CLASS_TOTAL_INCREASING
|
||||
)
|
||||
|
||||
|
||||
def test_config_schema_applies_tag_default_accuracy_decimals():
|
||||
"""Same root cause as the state_class regression: reintroducing
|
||||
sensor_schema(accuracy_decimals=...) would make V1 resolve to the
|
||||
schema-level default instead of the prefix-specific value of 2.
|
||||
"""
|
||||
result = _resolve_via_config_schema("V1")
|
||||
assert result[CONF_ACCURACY_DECIMALS] == 2
|
||||
|
||||
|
||||
def _make_config(tag: str) -> dict:
|
||||
"""Minimal config dict with only tag_name set — no overrides."""
|
||||
return {"tag_name": tag}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "expected_state_class", "expected_decimals"),
|
||||
[
|
||||
# Known numeric-index prefixes
|
||||
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("P1", STATE_CLASS_MEASUREMENT, 0),
|
||||
("V1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("I1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("T1", STATE_CLASS_MEASUREMENT, 2),
|
||||
# Known patterns
|
||||
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("PF1", STATE_CLASS_MEASUREMENT, 2),
|
||||
# Unknown / free-form tags fall back to generic defaults
|
||||
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
|
||||
("X", STATE_CLASS_MEASUREMENT, 0),
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
|
||||
"""apply_tag_defaults must inject the correct state_class and accuracy_decimals
|
||||
for each tag type when no user overrides are present."""
|
||||
config = _make_config(tag)
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class)
|
||||
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "user_state_class", "user_decimals"),
|
||||
[
|
||||
# User overrides must not be clobbered by defaults
|
||||
("E1", STATE_CLASS_MEASUREMENT, 3),
|
||||
("PULSE1", STATE_CLASS_MEASUREMENT, 1),
|
||||
("V1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4),
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults_respects_user_overrides(
|
||||
tag, user_state_class, user_decimals
|
||||
):
|
||||
"""apply_tag_defaults must not overwrite values already set by the user."""
|
||||
config = _make_config(tag)
|
||||
config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class)
|
||||
config[CONF_ACCURACY_DECIMALS] = user_decimals
|
||||
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class)
|
||||
assert result[CONF_ACCURACY_DECIMALS] == user_decimals
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
i2c:
|
||||
sda: 21
|
||||
scl: 22
|
||||
|
||||
output:
|
||||
- platform: ledc
|
||||
id: ledc_out
|
||||
pin: 25
|
||||
- platform: ac_dimmer
|
||||
id: dimmer_out
|
||||
gate_pin: 26
|
||||
zero_cross_pin: 27
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
mqtt:
|
||||
broker: "10.0.0.1"
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
uart:
|
||||
tx_pin: 17
|
||||
rx_pin: 16
|
||||
baud_rate: 115200
|
||||
|
||||
display:
|
||||
- platform: nextion
|
||||
tft_url: "http://10.0.0.1/display.tft"
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
web_server:
|
||||
version: 3
|
||||
@@ -236,6 +236,62 @@ def test_esp32_configuration_errors(
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "reincluded"),
|
||||
[
|
||||
pytest.param(
|
||||
"exclusion_reincludes.yaml",
|
||||
("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"),
|
||||
id="i2c_ledc_ac_dimmer",
|
||||
),
|
||||
# esp-tls has three owners; a per-owner config makes a dropped
|
||||
# re-include from any single one fail the test.
|
||||
pytest.param(
|
||||
"exclusion_reincludes_http_request.yaml",
|
||||
("esp-tls", "esp_http_client"),
|
||||
id="http_request",
|
||||
),
|
||||
pytest.param(
|
||||
# "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it
|
||||
# is a managed component and never leaves the exclusion set.
|
||||
"exclusion_reincludes_mqtt.yaml",
|
||||
("esp-tls",),
|
||||
id="mqtt",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_web_server.yaml",
|
||||
("esp-tls",),
|
||||
id="web_server_idf",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_nextion.yaml",
|
||||
("esp-tls", "esp_http_client"),
|
||||
id="nextion",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_default_exclusions_reincluded_by_owning_components(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
reincluded: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Components whose IDF driver is excluded by default must re-include it
|
||||
during codegen; a dropped include_builtin_idf_component() call would only
|
||||
surface as a missing-header failure in a full compile job."""
|
||||
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
|
||||
|
||||
generate_main(component_config_path(config_file))
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
for name in reincluded:
|
||||
assert name not in excluded, f"{name} should have been re-included"
|
||||
|
||||
# Components no part of this config touches stay excluded.
|
||||
assert "unity" in excluded
|
||||
assert "fatfs" in excluded
|
||||
|
||||
|
||||
def test_execute_from_psram_s3_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
esphome:
|
||||
name: scan-window-explicit
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
window: 30ms
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: scan-window-raised
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: scan-window-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: scan-window-user-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
connection_scan_window: 20ms
|
||||
@@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_device_base import to_ble_units
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_ble_tracker import (
|
||||
@@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected(
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_scan_params({"scan_parameters": {"interval": "20ms"}})
|
||||
|
||||
|
||||
# The connection-time fallback window: while a GATT connection is active the
|
||||
# scanner drops from a raised full-duty window back to this value so the
|
||||
# connection gets guaranteed airtime.
|
||||
|
||||
|
||||
def test_raise_arms_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
|
||||
|
||||
|
||||
def test_user_connection_scan_window_survives_raise(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
|
||||
|
||||
|
||||
def test_unraised_window_gets_no_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.4", wifi=True)
|
||||
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_interval_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_window_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window above the (post-raise) window would widen the scan
|
||||
during connections; the reject runs after the raise so a fallback below a
|
||||
raised window still validates (covered by the survives-raise test)."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params(
|
||||
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
|
||||
)
|
||||
|
||||
|
||||
def test_connection_scan_window_truncation_collapse_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window that truncates into the interval's 0.625 ms unit
|
||||
would silently program a full-duty scan during connections."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
|
||||
_scan_params(
|
||||
{
|
||||
"scan_parameters": {
|
||||
"interval": "320.5ms",
|
||||
"connection_scan_window": "320.2ms",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "window_call", "connection_call", "warns"),
|
||||
[
|
||||
# Raised window with GATT clients: the injected fallback is emitted.
|
||||
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
|
||||
# Explicit window: nothing injected.
|
||||
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
|
||||
# Scan-only build compiles the path out: the injected default is
|
||||
# dropped silently, a user-set value warns.
|
||||
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
|
||||
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
|
||||
],
|
||||
)
|
||||
def test_connection_scan_window_codegen(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
config_file: str,
|
||||
window_call: str,
|
||||
connection_call: bool,
|
||||
warns: bool,
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path(config_file))
|
||||
assert window_call in main_cpp
|
||||
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
|
||||
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT"
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_basic_setup(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
@@ -69,3 +74,62 @@ def test_gpio_binary_sensor_explicit_polling_mode(
|
||||
)
|
||||
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_interrupt_emits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
An interrupt-mode sensor must emit the define that compiles the ISR code,
|
||||
since the platform ISR pin implementation is only built when needed
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_polling_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
A polling-only config must not emit the interrupt define, so the ISR code
|
||||
(and its reference to ISRInternalGPIOPin) is compiled out
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_mixed_modes_emit_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
With one interrupt and one polling sensor, the define is emitted and the
|
||||
polling instance still opts out via its setter
|
||||
"""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml"
|
||||
)
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
assert "bs_interrupt->set_use_interrupt" not in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_expander_pin_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
An expander pin can't use interrupts: final validation falls back to
|
||||
polling and the interrupt define must not be emitted. This is the config
|
||||
that fails to link if the ISR code is compiled without an internal pin
|
||||
"""
|
||||
with caplog.at_level(logging.INFO):
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml"
|
||||
)
|
||||
|
||||
assert "bs_expander->set_use_interrupt(false);" in main_cpp
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
assert "falling back to polling mode" in caplog.text
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2c:
|
||||
scl: 16
|
||||
sda: 17
|
||||
|
||||
ch422g:
|
||||
- id: ch422g_hub
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
name: "Expander Sensor"
|
||||
id: bs_expander
|
||||
pin:
|
||||
ch422g: ch422g_hub
|
||||
number: 1
|
||||
mode: INPUT
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
pin: 5
|
||||
name: "Interrupt Sensor"
|
||||
id: bs_interrupt
|
||||
|
||||
- platform: gpio
|
||||
pin: 4
|
||||
name: "Polling Sensor"
|
||||
id: bs_polling
|
||||
use_interrupt: false
|
||||
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
|
||||
CONFIG_SCHEMA,
|
||||
MODBUS_CLIENT_SEND_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
CONF_ON_RESPONSE,
|
||||
)
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_on_write_pdu_rejected() -> None:
|
||||
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
|
||||
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
|
||||
CONF_CONTINUOUS: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_on_read_pdu_accepted() -> None:
|
||||
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
||||
CONF_CONTINUOUS: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
|
||||
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
|
||||
# components rather than this one.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Config validation for custom_pdu and the deprecated custom_command alias.
|
||||
|
||||
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
|
||||
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
|
||||
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
|
||||
test cannot: a write-coded custom_pdu polled continuously is rejected there.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller import (
|
||||
ModbusItemBaseSchema,
|
||||
validate_custom_pdu_item,
|
||||
)
|
||||
from esphome.components.modbus_controller.const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
|
||||
from esphome.core import ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
def test_custom_command_accepted_at_schema_level() -> None:
|
||||
"""custom_command validates at the schema level; migration/rejection happens in final validate."""
|
||||
config = ModbusItemBaseSchema(
|
||||
{CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]}
|
||||
)
|
||||
assert config[CONF_CUSTOM_COMMAND] == [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
|
||||
|
||||
def test_custom_pdu_and_custom_command_mutually_exclusive() -> None:
|
||||
"""Only one custom source may be given; supplying both is a schema error."""
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
ModbusItemBaseSchema(
|
||||
{
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_custom_pdu_accepted() -> None:
|
||||
"""The new key takes PDU bytes (function code + data, no address byte)."""
|
||||
config = ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01]})
|
||||
assert config[CONF_CUSTOM_PDU] == [0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
|
||||
|
||||
def test_custom_pdu_rejects_non_byte_values() -> None:
|
||||
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
|
||||
|
||||
|
||||
def _controller_full_config(*, continuous: bool) -> Config:
|
||||
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
|
||||
final-validate to resolve the controller (and its continuous flag) from an item's
|
||||
modbus_controller_id."""
|
||||
ctl_id = ID("ctl", is_declaration=True)
|
||||
config = Config()
|
||||
config["modbus_controller"] = [
|
||||
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
|
||||
]
|
||||
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_full_config():
|
||||
token = fv.full_config.set(Config())
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
|
||||
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
|
||||
rejected at final validate: the hub would strip continuous from the mutating code and warn on
|
||||
every update."""
|
||||
fv.full_config.set(_controller_full_config(continuous=True))
|
||||
with pytest.raises(Invalid, match="can't be polled continuously"):
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
|
||||
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
|
||||
fv.full_config.set(_controller_full_config(continuous=True))
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
network:
|
||||
tcp_send_buffer: 32kB
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
network:
|
||||
enable_high_performance: true
|
||||
tcp_send_buffer: 16384
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the ``network: tcp_send_buffer:`` option.
|
||||
|
||||
The option sets lwIP's per-socket TCP send buffer
|
||||
(CONFIG_LWIP_TCP_SND_BUF_DEFAULT) on ESP-IDF. The stock default (5744 bytes)
|
||||
stalls bursty senders such as a Bluetooth proxy streaming GATT notifications;
|
||||
until now the only way to raise it was the all-or-nothing
|
||||
``enable_high_performance`` bundle.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_SDKCONFIG_OPTIONS,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
)
|
||||
from esphome.components.network import (
|
||||
CONF_TCP_SEND_BUFFER,
|
||||
CONFIG_SCHEMA,
|
||||
TCP_SEND_BUFFER_MAX,
|
||||
TCP_SEND_BUFFER_MIN,
|
||||
)
|
||||
from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _sdkconfig_option(name: str) -> int | None:
|
||||
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
|
||||
|
||||
|
||||
def test_tcp_send_buffer_sets_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("tcp_send_buffer.yaml"))
|
||||
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000
|
||||
|
||||
|
||||
def test_tcp_send_buffer_overrides_high_performance(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""An explicit size wins over the high performance bundle's 65534."""
|
||||
generate_main(component_config_path("tcp_send_buffer_high_perf.yaml"))
|
||||
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX])
|
||||
def test_boundary_values_accepted(
|
||||
set_core_config: SetCoreConfigCallable, value: int
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
assert CONFIG_SCHEMA({"tcp_send_buffer": value})[CONF_TCP_SEND_BUFFER] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1kB", "128kB"])
|
||||
def test_out_of_range_rejected(
|
||||
set_core_config: SetCoreConfigCallable, value: str
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
with pytest.raises(Invalid):
|
||||
CONFIG_SCHEMA({"tcp_send_buffer": value})
|
||||
|
||||
|
||||
def test_rejected_on_esp8266(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP8266_ARDUINO,
|
||||
core_data={KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)},
|
||||
)
|
||||
with pytest.raises(Invalid, match="esp32"):
|
||||
CONFIG_SCHEMA({"tcp_send_buffer": "32kB"})
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the shared noise encryption key helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.noise import decode_encryption_key, validate_encryption_key
|
||||
|
||||
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
|
||||
def test_validate_encryption_key_roundtrips() -> None:
|
||||
assert validate_encryption_key(KEY) == KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["not-base64!!!", "AAECAw=="])
|
||||
def test_validate_encryption_key_rejects_bad_input(value: str) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
validate_encryption_key(value)
|
||||
|
||||
|
||||
def test_decode_encryption_key_returns_32_bytes() -> None:
|
||||
assert decode_encryption_key(KEY) == bytes(range(32))
|
||||
|
||||
|
||||
def test_decode_encryption_key_rejects_invalid_base64() -> None:
|
||||
"""The shared helper raises cv.Invalid, not binascii.Error."""
|
||||
with pytest.raises(cv.Invalid, match="base64"):
|
||||
decode_encryption_key("A")
|
||||
|
||||
|
||||
def test_decode_encryption_key_rejects_short_decode() -> None:
|
||||
"""a2b_base64 stops at embedded padding; a short decode must not become
|
||||
a zero padded PSK on the device."""
|
||||
with pytest.raises(cv.Invalid, match="32 bytes"):
|
||||
decode_encryption_key("AAECAw==")
|
||||
@@ -4,4 +4,5 @@ media_source:
|
||||
- platform: audio_http
|
||||
id: audio_http_source
|
||||
buffer_size: 100000
|
||||
persistent_ring_buffer: true
|
||||
task_stack_in_psram: true
|
||||
|
||||
@@ -136,3 +136,19 @@ binary_sensor:
|
||||
invalid_cooldown: 2s
|
||||
then:
|
||||
- logger.log: "Click with custom cooldown"
|
||||
|
||||
# Test on_click and on_double_click (compiles match_interval via
|
||||
# USE_BINARY_SENSOR_CLICK_TRIGGER)
|
||||
- platform: template
|
||||
id: click_triggers
|
||||
name: "Click Triggers"
|
||||
on_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Clicked"
|
||||
on_double_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Double clicked"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# No host camera platform exists to emit USE_CAMERA; define it here so
|
||||
# the iterator CAMERA state compiles into the test binary.
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_CAMERA")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,79 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/component_iterator.h"
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
#include "esphome/components/camera/camera.h"
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
class StubCamera : public camera::Camera {
|
||||
public:
|
||||
void add_listener(camera::CameraListener *listener) override {}
|
||||
camera::CameraImageReader *create_image_reader() override { return nullptr; }
|
||||
void request_image(camera::CameraRequester requester) override {}
|
||||
void start_stream(camera::CameraRequester requester) override {}
|
||||
void stop_stream(camera::CameraRequester requester) override {}
|
||||
};
|
||||
|
||||
// Iterator that accepts everything except the camera, which can refuse a
|
||||
// configurable number of times. The CAMERA state is a singleton path
|
||||
// distinct from process_platform_item_; this pins the same contract:
|
||||
// a refused camera is re-offered, never skipped.
|
||||
class CameraRefusingIterator : public ComponentIterator {
|
||||
public:
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *obj) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
bool on_camera(camera::Camera *obj) override {
|
||||
this->camera_calls++;
|
||||
if (this->camera_refusals > 0) {
|
||||
this->camera_refusals--;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int camera_calls{0};
|
||||
int camera_refusals{0};
|
||||
};
|
||||
|
||||
// Far above the fixed number of iterator states
|
||||
static constexpr size_t BIG_BUDGET = 1000;
|
||||
|
||||
class ComponentIteratorCameraTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Constructing a Camera installs the process-wide singleton
|
||||
static StubCamera stub_camera;
|
||||
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
|
||||
CameraRefusingIterator it;
|
||||
it.camera_refusals = 2;
|
||||
it.begin();
|
||||
// Runs until the camera refuses, which stops the pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.camera_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The camera is re-offered once per call, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.camera_calls, 2);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// Once accepted, the iteration completes
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.camera_calls, 3);
|
||||
}
|
||||
|
||||
} // namespace esphome::testing
|
||||
#endif // USE_CAMERA
|
||||
@@ -0,0 +1,11 @@
|
||||
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
|
||||
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
|
||||
# An alphabetically-earlier component's sensor: block shadows this one in
|
||||
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
|
||||
sensor:
|
||||
- platform: template
|
||||
id: bench_sensor_a
|
||||
name: "Bench A"
|
||||
- platform: template
|
||||
id: bench_sensor_b
|
||||
name: "Bench B"
|
||||
@@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) {
|
||||
EXPECT_EQ(dst[2], 3);
|
||||
}
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) {
|
||||
const char *haystack = nullptr;
|
||||
const char *needle = nullptr;
|
||||
|
||||
EXPECT_FALSE(str_contains_ignore_case(haystack, needle));
|
||||
EXPECT_FALSE(str_contains_ignore_case("Hello World", needle));
|
||||
EXPECT_FALSE(str_contains_ignore_case(haystack, "anything"));
|
||||
}
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) {
|
||||
const char *haystack = "Hello World";
|
||||
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, ""));
|
||||
}
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) {
|
||||
const char *haystack = "Hello World";
|
||||
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello"));
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello"));
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO"));
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO"));
|
||||
}
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, MiscNotMatching) {
|
||||
const char *haystack = "Hello World";
|
||||
|
||||
// Expected to match
|
||||
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell"));
|
||||
|
||||
// Expected not to match
|
||||
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven"));
|
||||
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!"));
|
||||
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!"));
|
||||
}
|
||||
|
||||
TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) {
|
||||
const char *haystack = "Hello World";
|
||||
for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) {
|
||||
EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle))
|
||||
<< "needle: " << needle;
|
||||
}
|
||||
EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", ""));
|
||||
EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc"));
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/component_iterator.h"
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
// Iterator whose begin/end callbacks can refuse a configurable number of
|
||||
// times; all entity callbacks accept (any registered entities are accepted).
|
||||
class RefusingIterator : public ComponentIterator {
|
||||
public:
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *obj) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
|
||||
bool on_end() override { return step(this->end_calls, this->end_refusals); }
|
||||
|
||||
int begin_calls{0};
|
||||
int end_calls{0};
|
||||
int begin_refusals{0};
|
||||
int end_refusals{0};
|
||||
|
||||
protected:
|
||||
static bool step(int &calls, int &refusals) {
|
||||
calls++;
|
||||
if (refusals > 0) {
|
||||
refusals--;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Far above the fixed number of iterator states
|
||||
static constexpr size_t BIG_BUDGET = 1000;
|
||||
|
||||
TEST(ComponentIterator, NotRunningMakesNoProgress) {
|
||||
RefusingIterator it;
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 0);
|
||||
EXPECT_EQ(it.end_calls, 0);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
|
||||
RefusingIterator it;
|
||||
it.begin();
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 1);
|
||||
EXPECT_EQ(it.end_calls, 1);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, StepBudgetIsHonored) {
|
||||
RefusingIterator it;
|
||||
it.begin();
|
||||
it.try_advance(1);
|
||||
EXPECT_EQ(it.begin_calls, 1);
|
||||
EXPECT_EQ(it.end_calls, 0);
|
||||
EXPECT_FALSE(it.completed());
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
|
||||
RefusingIterator it;
|
||||
it.end_refusals = 3;
|
||||
it.begin();
|
||||
// First call runs until the refused end step, which stops the pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.end_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The refused step is retried once per call, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.end_calls, 3);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// Once accepted, the iteration completes
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.end_calls, 4);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
|
||||
RefusingIterator it;
|
||||
it.begin_refusals = 2;
|
||||
it.begin();
|
||||
it.try_advance(BIG_BUDGET);
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.begin_calls, 2);
|
||||
EXPECT_FALSE(it.completed());
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 3);
|
||||
}
|
||||
|
||||
// The deprecated advance() wrapper must keep the legacy once-per-loop
|
||||
// pattern working during the deprecation window.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
|
||||
RefusingIterator it;
|
||||
it.end_refusals = 2;
|
||||
it.begin();
|
||||
size_t guard = 0;
|
||||
while (!it.completed() && guard++ < BIG_BUDGET) {
|
||||
it.advance();
|
||||
}
|
||||
EXPECT_TRUE(it.completed());
|
||||
// Two refused end steps were retried, then accepted
|
||||
EXPECT_EQ(it.end_calls, 3);
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// Iterator whose sensor callback can refuse or yield; pins the per-item
|
||||
// contract: a refused item is re-offered with at_ unchanged, never skipped.
|
||||
class ItemRefusingIterator : public RefusingIterator {
|
||||
public:
|
||||
bool on_sensor(sensor::Sensor *obj) override {
|
||||
this->last_sensor = obj;
|
||||
if (!step(this->sensor_calls, this->sensor_refusals))
|
||||
return false;
|
||||
if (this->yield_on_sensor)
|
||||
this->yield_after_step_();
|
||||
return true;
|
||||
}
|
||||
sensor::Sensor *last_sensor{nullptr};
|
||||
int sensor_calls{0};
|
||||
int sensor_refusals{0};
|
||||
bool yield_on_sensor{false};
|
||||
};
|
||||
|
||||
class ComponentIteratorSensorTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
static sensor::Sensor sensor_a;
|
||||
static sensor::Sensor sensor_b;
|
||||
static bool registered = false;
|
||||
if (!registered) {
|
||||
App.register_sensor(&sensor_a);
|
||||
App.register_sensor(&sensor_b);
|
||||
registered = true;
|
||||
}
|
||||
// StaticVector drops silently when full; fail the fixture, not the contract
|
||||
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
|
||||
ItemRefusingIterator it;
|
||||
it.sensor_refusals = 2;
|
||||
it.begin();
|
||||
// Runs until the first sensor refuses
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The refused item is re-offered, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 2);
|
||||
sensor::Sensor *refused = it.last_sensor;
|
||||
// Once accepted, iteration continues through the second sensor to the end
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_NE(it.last_sensor, refused);
|
||||
EXPECT_EQ(it.sensor_calls, 4);
|
||||
}
|
||||
|
||||
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
|
||||
ItemRefusingIterator it;
|
||||
it.yield_on_sensor = true;
|
||||
it.begin();
|
||||
// The pass ends right after the first sensor despite a big budget
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The next pass ends after the second sensor
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 2);
|
||||
// Remaining states then run to completion in one pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
}
|
||||
#endif // USE_SENSOR
|
||||
|
||||
} // namespace esphome::testing
|
||||
@@ -1,4 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
emontx: !include common.yaml
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
emontx: !include common.yaml
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
emontx: !include common.yaml
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
emontx: !include common.yaml
|
||||
|
||||
# Validate that each sensor type gets the correct default state_class,
|
||||
# unit_of_measurement, device_class, and accuracy_decimals when NO overrides
|
||||
# are provided. The values are intentionally omitted so apply_tag_defaults is
|
||||
# exercised, not the user-override path.
|
||||
|
||||
sensor:
|
||||
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
|
||||
# device_class=energy, accuracy_decimals=0
|
||||
- platform: emontx
|
||||
tag_name: E1
|
||||
name: Energy 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Power sensor (P prefix): expects state_class=measurement, unit=W,
|
||||
# device_class=power, accuracy_decimals=0
|
||||
- platform: emontx
|
||||
tag_name: P1
|
||||
name: Power 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Voltage sensor (V prefix): expects state_class=measurement, unit=V,
|
||||
# device_class=voltage, accuracy_decimals=2
|
||||
- platform: emontx
|
||||
tag_name: V1
|
||||
name: Voltage 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Current sensor (I prefix): expects state_class=measurement, unit=A,
|
||||
# device_class=current, accuracy_decimals=2
|
||||
- platform: emontx
|
||||
tag_name: I1
|
||||
name: Current 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Temperature sensor (T prefix): expects state_class=measurement, unit=°C,
|
||||
# device_class=temperature, accuracy_decimals=2
|
||||
- platform: emontx
|
||||
tag_name: T1
|
||||
name: Temperature 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Pulse sensor (PULSE pattern): expects state_class=total_increasing,
|
||||
# unit=pulses, device_class=energy, accuracy_decimals=0
|
||||
- platform: emontx
|
||||
tag_name: PULSE1
|
||||
name: Pulse 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Power factor sensor (PF pattern): expects state_class=measurement,
|
||||
# device_class=power_factor, accuracy_decimals=2
|
||||
- platform: emontx
|
||||
tag_name: PF1
|
||||
name: Power Factor 1
|
||||
emontx_id: test_emontx
|
||||
|
||||
# Unknown tag: no prefix match, falls back to state_class=measurement,
|
||||
# accuracy_decimals=0
|
||||
- platform: emontx
|
||||
tag_name: CUSTOM1
|
||||
name: Custom sensor
|
||||
emontx_id: test_emontx
|
||||
|
||||
# User override: verify that explicit values are respected and not clobbered
|
||||
- platform: emontx
|
||||
tag_name: E2
|
||||
name: Energy 2 (user override)
|
||||
emontx_id: test_emontx
|
||||
state_class: measurement
|
||||
accuracy_decimals: 3
|
||||
@@ -6,3 +6,6 @@ update:
|
||||
type: embedded
|
||||
path: $component_dir/test_firmware.bin
|
||||
sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31
|
||||
on_update_available:
|
||||
then:
|
||||
- logger.log: "Coprocessor update available"
|
||||
|
||||
@@ -8,3 +8,6 @@ update:
|
||||
type: http
|
||||
source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json
|
||||
update_interval: 6h
|
||||
on_update_available:
|
||||
then:
|
||||
- logger.log: "Coprocessor update available"
|
||||
|
||||
@@ -1,165 +1,112 @@
|
||||
#include <array>
|
||||
#include <utility>
|
||||
#include "../common.h"
|
||||
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
struct MitsubishiCN105ClimateTestContext {
|
||||
MitsubishiCN105Component component;
|
||||
MitsubishiCN105Climate sut;
|
||||
|
||||
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
|
||||
};
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
for (int temperature = 16; temperature <= 31; ++temperature) {
|
||||
EXPECT_EQ(context.component.get_temperature_mapping().to_mitsubishi(temperature), temperature);
|
||||
EXPECT_EQ(context.component.get_temperature_mapping().from_mitsubishi(temperature), temperature);
|
||||
}
|
||||
|
||||
const auto traits = context.sut.traits();
|
||||
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
context.component.set_use_fahrenheit(true);
|
||||
|
||||
const std::array cases{
|
||||
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
|
||||
std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f},
|
||||
std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f},
|
||||
std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f},
|
||||
std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f},
|
||||
std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f},
|
||||
};
|
||||
|
||||
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
|
||||
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().to_mitsubishi(fahrenheit), mitsubishi_celsius);
|
||||
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().from_mitsubishi(mitsubishi_celsius), fahrenheit);
|
||||
}
|
||||
const auto traits = context.sut.traits();
|
||||
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) {
|
||||
auto mapping = TemperatureMapping();
|
||||
mapping.set_use_fahrenheit(true);
|
||||
|
||||
const std::array cases{
|
||||
std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f},
|
||||
std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f},
|
||||
};
|
||||
|
||||
for (const auto &[celsius, fahrenheit] : cases) {
|
||||
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
|
||||
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
|
||||
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
|
||||
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
#include "../common.h"
|
||||
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
|
||||
SwingModeManager manager;
|
||||
climate::ClimateSwingModeMask supported_swing_modes;
|
||||
for (const auto mode : supported_modes)
|
||||
supported_swing_modes.insert(mode);
|
||||
manager.set_supported_swing_modes(supported_swing_modes);
|
||||
return manager;
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_VERTICAL});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_BOTH});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
|
||||
auto manager = make_swing_mode_manager({});
|
||||
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
|
||||
std::optional{MitsubishiCN105::WideVaneMode::SWING});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
@@ -42,11 +42,17 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
|
||||
// All bytes from UART should be consumed
|
||||
EXPECT_TRUE(ctx.uart.rx.empty());
|
||||
// After successful connect we request status, first settings (0x02)
|
||||
// Defer the first settings request (0x02) until the next update.
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
|
||||
ctx.sut.set_current_time(201);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 201);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
@@ -75,15 +81,24 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
|
||||
|
||||
// Now fetch telemetry (0x03)
|
||||
// Defer the telemetry request (0x03) until the next update.
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
|
||||
ctx.sut.set_current_time(301);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 301);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Queue a setting while waiting for telemetry.
|
||||
ctx.sut.set_power(true);
|
||||
|
||||
// Telemetry response
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
|
||||
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
|
||||
@@ -103,6 +118,13 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
|
||||
|
||||
// Apply the pending setting on the next update, outside RX processing.
|
||||
ctx.sut.set_current_time(401);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_FALSE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 401);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
@@ -469,6 +491,36 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, PendingSettingsTakePriorityOverDueTelemetry) {
|
||||
MitsubishiCN105TestsContext ctx;
|
||||
|
||||
ctx.sut.status_.target_temperature = 24.0f;
|
||||
ctx.sut.status_.room_temperature = 21.0f;
|
||||
ASSERT_TRUE(ctx.sut.is_status_initialized());
|
||||
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
ctx.sut.set_current_time(1000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
ctx.sut.set_power(true);
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
|
||||
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
|
||||
|
||||
ctx.sut.set_current_time(1001);
|
||||
ASSERT_TRUE(ctx.sut.update());
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
|
||||
ctx.sut.set_current_time(1002);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
|
||||
MitsubishiCN105TestsContext ctx;
|
||||
|
||||
|
||||
@@ -64,25 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
|
||||
};
|
||||
|
||||
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
|
||||
public:
|
||||
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
|
||||
|
||||
using MitsubishiCN105Climate::apply_values_;
|
||||
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
|
||||
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
|
||||
|
||||
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
|
||||
|
||||
protected:
|
||||
MitsubishiCN105Component component_;
|
||||
};
|
||||
|
||||
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
|
||||
public:
|
||||
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
|
||||
|
||||
void notify_status() { this->status_callback_.call(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -3,6 +3,7 @@ mitsubishi_cn105:
|
||||
uart_id: uart_bus
|
||||
update_interval: 30s
|
||||
telemetry_request_min_interval: 120s
|
||||
use_fahrenheit: true
|
||||
vane:
|
||||
on_state:
|
||||
- logger.log:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
size_t callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
hub.add_on_vane_state_callback([&](const VaneState &state) {
|
||||
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
hub.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
hub.set_target_temperature(20.0f);
|
||||
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(callback_count, 1);
|
||||
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
size_t status_callback_count = 0;
|
||||
size_t vane_callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
hub.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
hub.set_target_temperature(20.0f);
|
||||
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(status_callback_count, 1);
|
||||
EXPECT_EQ(vane_callback_count, 1);
|
||||
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
|
||||
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(status_callback_count, 2);
|
||||
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
|
||||
auto call = hub.make_vane_call();
|
||||
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
|
||||
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
|
||||
|
||||
action.play();
|
||||
|
||||
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
+15
-18
@@ -3,14 +3,9 @@
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
|
||||
public:
|
||||
using MitsubishiCN105VerticalVaneDirectionSelect::control;
|
||||
};
|
||||
|
||||
struct VerticalVaneDirectionSelectTestContext {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
MitsubishiCN105Component hub;
|
||||
MitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
|
||||
VerticalVaneDirectionSelectTestContext() {
|
||||
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
|
||||
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
|
||||
|
||||
for (size_t i = 0; i < expected_modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.select.control(i);
|
||||
ctx.select.make_call().set_index(i).perform();
|
||||
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
|
||||
VerticalVaneDirectionSelectTestContext ctx;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
|
||||
constexpr std::array modes{
|
||||
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
|
||||
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
|
||||
|
||||
for (size_t i = 0; i < modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.hub.mutable_status().vane_mode = modes[i];
|
||||
ctx.hub.notify_status();
|
||||
ctx.hub.set_vane_mode(modes[i]);
|
||||
ctx.hub.publish_status();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
|
||||
}
|
||||
|
||||
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
ctx.hub.notify_status();
|
||||
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
|
||||
}
|
||||
|
||||
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
climate_entity.setup();
|
||||
|
||||
ctx.select.control(6);
|
||||
ctx.select.make_call().set_index(6).perform();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.select.control(3);
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
climate_entity.setup();
|
||||
|
||||
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
|
||||
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
|
||||
VerticalVaneDirectionSelectTestContext ctx;
|
||||
|
||||
ctx.select.control(3);
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
|
||||
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
|
||||
EXPECT_FALSE(ctx.select.has_state());
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
hub.force_send_next();
|
||||
|
||||
// A matching successful response cycles the continuous entry back to READY.
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
// An exception response ends the poll.
|
||||
hub.force_send_next();
|
||||
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
hub.force_send_next();
|
||||
|
||||
hub.timeout_waiting(); // no response -> device requests retry
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
|
||||
}
|
||||
|
||||
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
|
||||
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_FALSE(hub.queued(0).continuous);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u);
|
||||
|
||||
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
|
||||
hub.force_send_next();
|
||||
EXPECT_FALSE(hub.waiting_command().continuous);
|
||||
EXPECT_FALSE(hub.waiting_command().options.continuous);
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
hub.force_send_next();
|
||||
const uint8_t exception_response[] = {0x83, 0x02};
|
||||
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
|
||||
|
||||
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
|
||||
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
|
||||
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
|
||||
|
||||
// And it runs to its own terminal - a good response this time - then the entry is gone.
|
||||
hub.force_send_next();
|
||||
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
|
||||
|
||||
device.read_holding_registers(0x100, 2);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_FALSE(hub.queued(0).continuous);
|
||||
ASSERT_FALSE(hub.queued(0).options.continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
// And it behaves as a poll from here: success cycles it back to READY.
|
||||
hub.force_send_next();
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
}
|
||||
|
||||
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
|
||||
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
|
||||
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
|
||||
hub.timeout_waiting();
|
||||
hub.force_send_next();
|
||||
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
|
||||
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
|
||||
}
|
||||
|
||||
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
|
||||
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
|
||||
device.queue_pdu(write_pdu, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
|
||||
EXPECT_FALSE(hub.queued(0).continuous);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
}
|
||||
|
||||
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
|
||||
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
|
||||
|
||||
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
|
||||
|
||||
device.read_holding_registers(0x200, 2); // a one-shot does count
|
||||
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
|
||||
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
|
||||
}
|
||||
|
||||
// An exception-flagged function code is never silently re-sendable, even though the read check
|
||||
|
||||
@@ -51,6 +51,7 @@ button:
|
||||
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
|
||||
- modbus_client.send:
|
||||
address: 0x01
|
||||
continuous: true
|
||||
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
|
||||
- modbus_client.send:
|
||||
address: !lambda "return 1;"
|
||||
@@ -91,6 +92,7 @@ button:
|
||||
address: !lambda "return 1;"
|
||||
start_address: 0x10
|
||||
count: 2
|
||||
continuous: true
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
|
||||
@@ -98,6 +100,7 @@ button:
|
||||
then:
|
||||
- logger.log: "typed read timeout"
|
||||
- modbus_client.read_input_registers:
|
||||
continuous: !lambda "return false;"
|
||||
address: 0x01
|
||||
start_address: 0x20
|
||||
on_custom_response:
|
||||
@@ -113,12 +116,14 @@ button:
|
||||
address: 0x01
|
||||
start_address: 0x03
|
||||
count: 16
|
||||
continuous: true
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
|
||||
- modbus_client.read_discrete_inputs:
|
||||
address: 0x01
|
||||
start_address: 0x00
|
||||
continuous: true
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
|
||||
|
||||
@@ -2,6 +2,7 @@ modbus_controller:
|
||||
- id: modbus_controller1
|
||||
address: 0x2
|
||||
modbus_id: modbus_bus
|
||||
continuous: true
|
||||
on_online:
|
||||
then:
|
||||
logger.log: "Module Online"
|
||||
@@ -108,6 +109,22 @@ select:
|
||||
return value;
|
||||
|
||||
sensor:
|
||||
# custom_pdu polls a ready-made PDU (function code + data - no device address byte, no CRC); covers
|
||||
# the set_custom_pdu codegen path and the custom-range polling constructor.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor_custom_pdu
|
||||
name: Test Custom PDU Sensor
|
||||
custom_pdu: [0x03, 0x00, 0x2A, 0x00, 0x01]
|
||||
value_type: U_WORD
|
||||
# Deprecated custom_command (leading byte 0x02 == modbus_controller1's address) drives the
|
||||
# migrate_custom_command final-validate auto-migration path in CI.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor_custom_command
|
||||
name: Test Custom Command Sensor
|
||||
custom_command: [0x02, 0x03, 0x00, 0x2B, 0x00, 0x01]
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor1
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
network:
|
||||
enable_high_performance: true
|
||||
tcp_send_buffer: 32kB
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# to_code must run: it defines USE_NOISE and adds the noise-c library
|
||||
# the component sources under test need.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1 @@
|
||||
noise:
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,199 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
|
||||
namespace esphome::noise::testing {
|
||||
|
||||
using Action = NoiseResponderHandshake::Action;
|
||||
|
||||
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
|
||||
// pattern the responder class implements, so the tests exercise a real
|
||||
// two-message handshake rather than mirrored calls into the class under test.
|
||||
class Initiator {
|
||||
public:
|
||||
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
|
||||
const NoiseProtocolId nid = {
|
||||
.prefix_id = NOISE_PREFIX_STANDARD,
|
||||
.pattern_id = NOISE_PATTERN_NN,
|
||||
.modifier_ids = {NOISE_MODIFIER_PSK0},
|
||||
.dh_id = NOISE_DH_CURVE25519,
|
||||
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
};
|
||||
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
|
||||
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
|
||||
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
|
||||
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
|
||||
}
|
||||
~Initiator() {
|
||||
if (this->state_ != nullptr)
|
||||
noise_handshakestate_free(this->state_);
|
||||
if (this->send_ != nullptr)
|
||||
noise_cipherstate_free(this->send_);
|
||||
if (this->recv_ != nullptr)
|
||||
noise_cipherstate_free(this->recv_);
|
||||
}
|
||||
Initiator(const Initiator &) = delete;
|
||||
Initiator &operator=(const Initiator &) = delete;
|
||||
|
||||
size_t write_message(uint8_t *out, size_t capacity) {
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_output(mbuf, out, capacity);
|
||||
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
|
||||
return mbuf.size;
|
||||
}
|
||||
|
||||
int read_message(uint8_t *data, size_t len) {
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_input(mbuf, data, len);
|
||||
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
|
||||
}
|
||||
|
||||
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
|
||||
|
||||
NoiseCipherState *send_{nullptr};
|
||||
NoiseCipherState *recv_{nullptr};
|
||||
|
||||
private:
|
||||
NoiseHandshakeState *state_{nullptr};
|
||||
};
|
||||
|
||||
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
|
||||
|
||||
static psk_t make_psk(uint8_t seed) {
|
||||
psk_t psk;
|
||||
for (size_t i = 0; i < psk.size(); i++) {
|
||||
psk[i] = static_cast<uint8_t>(seed + i);
|
||||
}
|
||||
return psk;
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
|
||||
NoiseResponderHandshake handshake;
|
||||
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
|
||||
// The class doc promises a noise-c error, not a crash, when the message
|
||||
// methods run outside their action() step; pin the library's null check
|
||||
NoiseResponderHandshake handshake;
|
||||
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
|
||||
size_t out_len = 0;
|
||||
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
|
||||
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
|
||||
// Deliberately non-null: split() documents a nullptr postcondition on
|
||||
// error, so a caller's uninitialized locals never hold garbage to free
|
||||
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
|
||||
NoiseCipherState *send_cipher = sentinel;
|
||||
NoiseCipherState *recv_cipher = sentinel;
|
||||
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
|
||||
EXPECT_EQ(send_cipher, nullptr);
|
||||
EXPECT_EQ(recv_cipher, nullptr);
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
size_t msg_len = initiator.write_message(msg, sizeof(msg));
|
||||
ASSERT_GT(msg_len, 0u);
|
||||
|
||||
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
|
||||
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
|
||||
|
||||
size_t reply_len = 0;
|
||||
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
|
||||
ASSERT_GT(reply_len, 0u);
|
||||
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
|
||||
|
||||
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
|
||||
initiator.split();
|
||||
|
||||
NoiseCipherState *send_cipher = nullptr;
|
||||
NoiseCipherState *recv_cipher = nullptr;
|
||||
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
|
||||
ASSERT_NE(send_cipher, nullptr);
|
||||
ASSERT_NE(recv_cipher, nullptr);
|
||||
// The handshake state is released by split(); the class reports FAILED after
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
|
||||
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
|
||||
|
||||
// Responder encrypts, initiator decrypts
|
||||
uint8_t frame[64];
|
||||
static constexpr char PLAINTEXT[] = "encrypted ota";
|
||||
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
|
||||
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
|
||||
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
|
||||
|
||||
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
|
||||
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
|
||||
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
|
||||
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
|
||||
|
||||
noise_cipherstate_free(send_cipher);
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
// The documented retry shape: a repeated init() frees the previous state
|
||||
// and starts over. The first message under the new key authenticating
|
||||
// proves the restart took effect; the old state surviving would fail the
|
||||
// MAC here.
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
size_t msg_len = initiator.write_message(msg, sizeof(msg));
|
||||
ASSERT_GT(msg_len, 0u);
|
||||
EXPECT_EQ(responder.read_message(msg, msg_len), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
size_t msg_len = initiator.write_message(msg, sizeof(msg));
|
||||
ASSERT_GT(msg_len, 0u);
|
||||
|
||||
int err = responder.read_message(msg, msg_len);
|
||||
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
|
||||
}
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
|
||||
// The prologue binds the plaintext preamble for downgrade resistance; a
|
||||
// tampered preamble must fail even with the right key.
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
static const uint8_t TAMPERED[] = {'x'};
|
||||
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
size_t msg_len = initiator.write_message(msg, sizeof(msg));
|
||||
ASSERT_GT(msg_len, 0u);
|
||||
|
||||
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
|
||||
}
|
||||
|
||||
} // namespace esphome::noise::testing
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/components/noise/noise.h"
|
||||
|
||||
namespace esphome::noise::testing {
|
||||
|
||||
TEST(NoiseContextTest, AllZerosPskIsReserved) {
|
||||
psk_t zeros{};
|
||||
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
|
||||
|
||||
psk_t psk{};
|
||||
psk[31] = 1;
|
||||
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
|
||||
|
||||
NoiseContext ctx;
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(zeros);
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(psk);
|
||||
EXPECT_TRUE(ctx.has_psk());
|
||||
EXPECT_EQ(ctx.get_psk(), psk);
|
||||
}
|
||||
|
||||
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
|
||||
uint8_t header[FRAME_HEADER_SIZE];
|
||||
write_frame_header(header, 0x1234);
|
||||
EXPECT_EQ(header[0], FRAME_INDICATOR);
|
||||
EXPECT_EQ(header[1], 0x12);
|
||||
EXPECT_EQ(header[2], 0x34);
|
||||
}
|
||||
|
||||
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
|
||||
// The MAC failure string is a wire contract: clients match it to report a
|
||||
// wrong key. Format the payload exactly the way the handshake read path does.
|
||||
uint8_t buf[64];
|
||||
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
|
||||
static constexpr char EXPECTED[] = "Handshake MAC failure";
|
||||
ASSERT_EQ(len, 1 + strlen(EXPECTED));
|
||||
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
|
||||
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
|
||||
// The exported floor covers the full MAC failure payload exactly
|
||||
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
|
||||
|
||||
// Any other error maps to the generic reason
|
||||
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
|
||||
static constexpr char GENERIC[] = "Handshake error";
|
||||
ASSERT_EQ(len, 1 + strlen(GENERIC));
|
||||
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
|
||||
}
|
||||
|
||||
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
|
||||
uint8_t buf[8];
|
||||
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
|
||||
ASSERT_EQ(len, sizeof(buf));
|
||||
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
|
||||
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
|
||||
|
||||
// A one-byte buffer still carries the status byte
|
||||
uint8_t tiny[1];
|
||||
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
|
||||
ASSERT_EQ(len, 1u);
|
||||
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
|
||||
|
||||
// A zero-capacity buffer yields no payload and stays untouched
|
||||
uint8_t none[1] = {0xAA};
|
||||
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
|
||||
EXPECT_EQ(none[0], 0xAA);
|
||||
}
|
||||
|
||||
} // namespace esphome::noise::testing
|
||||
@@ -57,6 +57,11 @@ image:
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: JPG
|
||||
type: RGB565
|
||||
- platform: online_image
|
||||
id: online_auto_image
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
|
||||
# Check the set_url action
|
||||
esphome:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
|
||||
// erased watermark must always cover the write end, stay 64 KiB block-aligned
|
||||
// until the clamp, and never exceed the partition.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::ota::testing {
|
||||
|
||||
static constexpr size_t BLOCK = 64 * 1024;
|
||||
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
|
||||
|
||||
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ClampsToPartitionEnd) {
|
||||
// Partition sizes are sector multiples but not always block multiples
|
||||
constexpr size_t part = 27 * BLOCK + 4096;
|
||||
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
|
||||
EXPECT_EQ(next_erase_end(part, part), part);
|
||||
}
|
||||
|
||||
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
|
||||
// a write past that seed must still cover the write end.
|
||||
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
|
||||
for (size_t end = 1; end <= PART; end += 4093) {
|
||||
const size_t erased = next_erase_end(end, PART);
|
||||
ASSERT_GE(erased, end);
|
||||
ASSERT_LE(erased, PART);
|
||||
// Block-aligned unless clamped at the partition end
|
||||
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ota::testing
|
||||
@@ -1,2 +1,9 @@
|
||||
preferences:
|
||||
id: prefs_syncer
|
||||
flash_write_interval: 20s
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- component.suspend: prefs_syncer
|
||||
- component.resume: prefs_syncer
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
remote_transmitter:
|
||||
id: xmitr
|
||||
pin: GPIO26
|
||||
carrier_duty_percent: 50%
|
||||
|
||||
packages:
|
||||
buttons: !include common-buttons.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
remote_transmitter:
|
||||
id: xmitr
|
||||
pin: GPIO12
|
||||
carrier_duty_percent: 50%
|
||||
|
||||
packages:
|
||||
buttons: !include common-buttons.yaml
|
||||
@@ -0,0 +1,15 @@
|
||||
from esphome.components.runtime_image import enable_format
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# to_code is suppressed in cpptest builds; formats are normally enabled by
|
||||
# process_runtime_image_config(). Enable all formats so the format-switch
|
||||
# tests have two decoder types and every retained decoder is under test.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
enable_format("BMP")
|
||||
enable_format("PNG")
|
||||
enable_format("JPEG")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,356 @@
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
|
||||
// 3x2 24bpp BMP, every pixel a unique color (rows padded to 4 bytes)
|
||||
static const uint8_t BMP_24BPP[] = {
|
||||
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
|
||||
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00,
|
||||
0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t BMP_24BPP_EXPECTED[2][3][3] = {
|
||||
{{0xE0, 0x10, 0x20}, {0x30, 0xC0, 0x40}, {0x50, 0x60, 0xA0}},
|
||||
{{0x11, 0x22, 0x33}, {0x99, 0x88, 0x77}, {0xAB, 0xCD, 0xEF}},
|
||||
};
|
||||
|
||||
// 3x2 8bpp BMP with a 4-entry color table
|
||||
static const uint8_t BMP_8BPP[] = {
|
||||
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x28, 0x00,
|
||||
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x04, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x10, 0x00, 0xD0, 0xE0, 0xF0, 0x00, 0x00, 0xFF,
|
||||
0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x03, 0x02, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t BMP_8BPP_EXPECTED[2][3][3] = {
|
||||
{{0x10, 0x20, 0x30}, {0xF0, 0xE0, 0xD0}, {0x00, 0xFF, 0x00}},
|
||||
{{0xFF, 0x00, 0xFF}, {0x00, 0xFF, 0x00}, {0xF0, 0xE0, 0xD0}},
|
||||
};
|
||||
|
||||
// 3x2 8bpp BMP with an 8-entry color table, all colors distinct from BMP_8BPP's
|
||||
static const uint8_t BMP_8BPP_BIG[] = {
|
||||
0x42, 0x4D, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00,
|
||||
0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x18, 0x08,
|
||||
0x00, 0xA8, 0xB8, 0xC8, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x55, 0x99,
|
||||
0x11, 0x00, 0xCC, 0x00, 0x66, 0x00, 0x44, 0x22, 0xEE, 0x00, 0x01, 0x06, 0x04, 0x00, 0x07, 0x05, 0x03, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t BMP_8BPP_BIG_EXPECTED[2][3][3] = {
|
||||
{{0xEE, 0x22, 0x44}, {0x11, 0x99, 0x55}, {0x80, 0xFF, 0x00}},
|
||||
{{0xC8, 0xB8, 0xA8}, {0x66, 0x00, 0xCC}, {0xFF, 0x80, 0x00}},
|
||||
};
|
||||
|
||||
// 4x4 RGB PNG, every pixel a unique color
|
||||
static const uint8_t PNG_RGB[] = {
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x04, 0x00, 0x00, 0x00, 0x04, 0x08, 0x02, 0x00, 0x00, 0x00, 0x26, 0x93, 0x09, 0x29, 0x00, 0x00, 0x00, 0x38, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x16, 0x50, 0x30, 0x58, 0xB0, 0xE1, 0xC0, 0xFF, 0xFF, 0x0C,
|
||||
0x0C, 0x0E, 0x0C, 0x50, 0xEC, 0xE0, 0xE0, 0xC0, 0x50, 0xCF, 0xF0, 0x9F, 0xA1, 0xFE, 0xFF, 0xFF, 0x7A, 0x86, 0xFA,
|
||||
0xFF, 0x0C, 0x0C, 0x42, 0x26, 0x61, 0xA9, 0xCE, 0x8A, 0xFF, 0xEE, 0xEC, 0x5A, 0x7D, 0xF6, 0x3D, 0x00, 0x81, 0xCB,
|
||||
0x12, 0x4D, 0xB3, 0xFB, 0xD4, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
};
|
||||
|
||||
static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
|
||||
{{0x01, 0x02, 0x03}, {0x10, 0x20, 0x30}, {0xA0, 0xB0, 0xC0}, {0xFF, 0xFF, 0x00}},
|
||||
{{0x40, 0x00, 0x00}, {0x00, 0x40, 0x00}, {0x00, 0x00, 0x40}, {0x40, 0x40, 0x40}},
|
||||
{{0x7F, 0x00, 0xFF}, {0x00, 0x7F, 0xFF}, {0xFF, 0x7F, 0x00}, {0x7F, 0xFF, 0x00}},
|
||||
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
|
||||
};
|
||||
|
||||
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
|
||||
class TestableRuntimeImage : public RuntimeImage {
|
||||
public:
|
||||
explicit TestableRuntimeImage(ImageFormat format)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
|
||||
|
||||
ImageDecoder *decoder() { return this->decoder_.get(); }
|
||||
};
|
||||
|
||||
/// Runs one full decode session. Returns true when every stage succeeded.
|
||||
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len, ImageFormat format = AUTO) {
|
||||
std::vector<uint8_t> buffer(data, data + len); // feed_data needs mutable bytes
|
||||
if (!img.begin_decode(len, format)) {
|
||||
return false;
|
||||
}
|
||||
size_t offset = 0;
|
||||
while (offset < len) {
|
||||
int consumed = img.feed_data(buffer.data() + offset, len - offset);
|
||||
if (consumed <= 0) {
|
||||
return false; // decode error, or no progress despite full data
|
||||
}
|
||||
offset += consumed;
|
||||
}
|
||||
return img.end_decode();
|
||||
}
|
||||
|
||||
/// Feeds the image the way online_image's download loop does: append a small
|
||||
/// chunk to a window, feed the window, drop what was consumed, repeat. A zero
|
||||
/// return mid-stream means "need more data" and grows the window.
|
||||
static bool decode_chunked(TestableRuntimeImage &img, const uint8_t *data, size_t len, size_t chunk_size) {
|
||||
if (!img.begin_decode(len)) {
|
||||
return false;
|
||||
}
|
||||
std::vector<uint8_t> window;
|
||||
size_t supplied = 0;
|
||||
while (supplied < len || !window.empty()) {
|
||||
if (supplied < len) {
|
||||
size_t take = std::min(chunk_size, len - supplied);
|
||||
window.insert(window.end(), data + supplied, data + supplied + take);
|
||||
supplied += take;
|
||||
}
|
||||
int consumed = img.feed_data(window.data(), window.size());
|
||||
if (consumed < 0 || (consumed == 0 && supplied >= len)) {
|
||||
return false; // decode error, or stuck with all data supplied
|
||||
}
|
||||
window.erase(window.begin(), window.begin() + consumed);
|
||||
}
|
||||
return img.end_decode();
|
||||
}
|
||||
|
||||
template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][3]) {
|
||||
ASSERT_EQ(img.get_width(), static_cast<int>(W));
|
||||
ASSERT_EQ(img.get_height(), static_cast<int>(H));
|
||||
for (size_t y = 0; y < H; y++) {
|
||||
for (size_t x = 0; x < W; x++) {
|
||||
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
|
||||
Color color = img.get_pixel(x, y);
|
||||
EXPECT_THAT((std::array<uint8_t, 3>{color.r, color.g, color.b}), ::testing::ElementsAreArray(expected[y][x]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP)));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP)));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, SecondDecodeStartsClean) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
// Palettized decode, then a 24bpp decode, then palettized again, all on the
|
||||
// same decoder: each session must produce correct pixels for its own image.
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP)));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP)));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP)));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, ColorTableGrowsAndShrinksAcrossReuse) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
// Small palette first: the retained table is allocated at 4 entries.
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP)));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
// Growing to 8 entries on the reused decoder must reallocate, not overflow.
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP_BIG, sizeof(BMP_8BPP_BIG)));
|
||||
expect_pixels(img, BMP_8BPP_BIG_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
|
||||
// Shrinking back must not surface stale colors from the larger table.
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP)));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
// Chunked again on the warm decoder: the cross-call resume state
|
||||
// (current_index_ / paint_index_) must have been fully reset.
|
||||
ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) {
|
||||
// Drive the format switch through begin_decode()'s format parameter, the way
|
||||
// a dynamic-format producer (online_image MIME detection) does.
|
||||
TestableRuntimeImage img(AUTO);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
|
||||
ASSERT_NE(img.decoder(), nullptr);
|
||||
ASSERT_EQ(img.decoder()->get_format(), BMP);
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
|
||||
// Same explicit format again: the decoder must stay warm.
|
||||
ImageDecoder *bmp_decoder = img.decoder();
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP), BMP));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), bmp_decoder);
|
||||
|
||||
// Different format: the stale decoder must be evicted and recreated.
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
EXPECT_EQ(img.decoder()->get_format(), PNG);
|
||||
expect_pixels(img, PNG_RGB_EXPECTED);
|
||||
|
||||
// And back again.
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
|
||||
EXPECT_EQ(img.decoder()->get_format(), BMP);
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, AutoFormatFallsBackToConfiguredAndKeepsDecoderWarm) {
|
||||
// With a configured format, an AUTO begin_decode() must resolve to the
|
||||
// configured format before the reuse check instead of evicting the decoder.
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
EXPECT_EQ(first->get_format(), BMP);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first) << "AUTO must not evict the configured-format decoder";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, AutoWithoutConfiguredFormatFails) {
|
||||
// Neither a configured format nor an explicit one: there is nothing to decode with.
|
||||
TestableRuntimeImage img(AUTO);
|
||||
EXPECT_FALSE(img.begin_decode(64));
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) {
|
||||
TestableRuntimeImage img(PNG);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB)));
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
img.release();
|
||||
EXPECT_EQ(img.decoder(), first) << "release() must keep the decoder for reuse";
|
||||
EXPECT_FALSE(img.is_decoding());
|
||||
EXPECT_EQ(img.get_width(), 0);
|
||||
EXPECT_EQ(img.get_height(), 0);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB)));
|
||||
expect_pixels(img, PNG_RGB_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, FailedDecodeRecovers) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
uint8_t garbage[32];
|
||||
memset(garbage, 'X', sizeof(garbage));
|
||||
ASSERT_TRUE(img.begin_decode(sizeof(garbage)));
|
||||
EXPECT_LT(img.feed_data(garbage, sizeof(garbage)), 0) << "garbage must fail to decode";
|
||||
img.release();
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP)));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
}
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
// 8x8 gradient JPEG (quality 90). JPEG is lossy, so the test asserts that a
|
||||
// reused decoder reproduces the exact same pixels, not absolute colors.
|
||||
static const uint8_t JPEG_GRADIENT[] = {
|
||||
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, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03,
|
||||
0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A,
|
||||
0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15,
|
||||
0x15, 0x15, 0x0C, 0x0F, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x03,
|
||||
0x04, 0x04, 0x05, 0x04, 0x05, 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14,
|
||||
0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14,
|
||||
0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14,
|
||||
0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 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, 0xE5, 0x3E, 0x0B, 0xFE,
|
||||
0xC8, 0x7F, 0xEA, 0x3F, 0xD0, 0xBD, 0x3F, 0x86, 0x8A, 0x28, 0xAA, 0xC2, 0x62, 0x6A, 0xFB, 0x25, 0xA9, 0xD5, 0xC0,
|
||||
0x7C, 0x6B, 0x9D, 0x7F, 0x62, 0xD3, 0xFD, 0xEF, 0xF5, 0xF7, 0x9F, 0xFF, 0xD9,
|
||||
};
|
||||
|
||||
static std::vector<uint8_t> pixel_bytes(TestableRuntimeImage &img) {
|
||||
const uint8_t *start = img.get_data_start();
|
||||
return std::vector<uint8_t>(start, start + img.get_width_stride() * img.get_height());
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(JPEG);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(img.get_width(), 8);
|
||||
ASSERT_EQ(img.get_height(), 8);
|
||||
std::vector<uint8_t> first_pixels = pixel_bytes(img);
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
|
||||
}
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
|
||||
|
||||
ASSERT_TRUE(img.begin_decode(buffer.size()));
|
||||
EXPECT_TRUE(img.is_decoding());
|
||||
EXPECT_FALSE(img.is_decode_finished());
|
||||
|
||||
ASSERT_EQ(img.feed_data(buffer.data(), buffer.size()), static_cast<int>(buffer.size()));
|
||||
EXPECT_TRUE(img.is_decode_finished()) << "all pixel data consumed";
|
||||
|
||||
ASSERT_TRUE(img.end_decode());
|
||||
EXPECT_FALSE(img.is_decoding()) << "end_decode() must close the session";
|
||||
EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'";
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image::testing
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
|
||||
TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
|
||||
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
|
||||
EXPECT_EQ(get_format_for_mime_type("Image/PNG"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("IMAGE/BMP"), BMP);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, FormatMatchesContentTypeWithParameters) {
|
||||
// Content-Type headers may carry parameters after the media type
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png; charset=binary"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/bmp;name=\"a.bmp\""), BMP);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
|
||||
EXPECT_EQ(get_format_for_mime_type("text/html"), std::nullopt);
|
||||
EXPECT_EQ(get_format_for_mime_type("application/octet-stream"), std::nullopt);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/*"), std::nullopt);
|
||||
EXPECT_EQ(get_format_for_mime_type(""), std::nullopt);
|
||||
EXPECT_EQ(get_format_for_mime_type(nullptr), std::nullopt);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
|
||||
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
// AUTO has no single MIME type and falls back to the wildcard
|
||||
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
|
||||
|
||||
// Every decodable format must resolve back to itself through its MIME type
|
||||
for (ImageFormat format : {
|
||||
BMP,
|
||||
PNG,
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
JPEG,
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
}) {
|
||||
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image::testing
|
||||
@@ -1,6 +1,6 @@
|
||||
# `sendspin.switch` action enables the controller role, so we use a standalone test
|
||||
packages:
|
||||
base: !include common.yaml
|
||||
sendspin: !include common.yaml
|
||||
|
||||
wifi:
|
||||
on_connect:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
sendspin_hub: !include common-hub.yaml
|
||||
|
||||
ethernet:
|
||||
type: OPENETH
|
||||
@@ -0,0 +1,6 @@
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
media_player:
|
||||
- platform: sendspin
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
media_source:
|
||||
- platform: sendspin
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
sensor:
|
||||
- platform: sendspin
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
text_sensor:
|
||||
- platform: sendspin
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
packages:
|
||||
sendspin_hub: !include common-hub.yaml
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common-action.yaml
|
||||
packages:
|
||||
sendspin: !include common-action.yaml
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
ethernet:
|
||||
type: OPENETH
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
packages:
|
||||
sendspin: !include common-ethernet.yaml
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common-media_player.yaml
|
||||
packages:
|
||||
sendspin: !include common-media_player.yaml
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common-media_source.yaml
|
||||
packages:
|
||||
sendspin: !include common-media_source.yaml
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common-sensor.yaml
|
||||
packages:
|
||||
sendspin: !include common-sensor.yaml
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common-text_sensor.yaml
|
||||
packages:
|
||||
sendspin: !include common-text_sensor.yaml
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
network:
|
||||
|
||||
api:
|
||||
|
||||
time:
|
||||
- platform: homeassistant
|
||||
# Angle-bracket name pins the explicit-timezone host codegen path
|
||||
# (setenv/tzset plus pre-parsed struct emission) with characters that
|
||||
# would break unescaped string interpolation.
|
||||
timezone: "<+07>-7"
|
||||
@@ -0,0 +1,130 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
|
||||
namespace esphome::wifi::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Stand-in for WiFiScanResult, which does not compile on the host.
|
||||
struct Entry {
|
||||
std::string ssid;
|
||||
int8_t rssi;
|
||||
bool with_auth{true};
|
||||
bool is_hidden{false};
|
||||
|
||||
// Compares length and bytes like CompactString does, so an embedded NUL counts.
|
||||
bool ssid_equals(const Entry &other) const { return this->ssid == other.ssid; }
|
||||
int8_t get_rssi() const { return this->rssi; }
|
||||
bool get_with_auth() const { return this->with_auth; }
|
||||
bool get_is_hidden() const { return this->is_hidden; }
|
||||
};
|
||||
|
||||
// One network as a consumer would emit it.
|
||||
struct Row {
|
||||
std::string ssid;
|
||||
int8_t rssi;
|
||||
bool lock;
|
||||
|
||||
bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; }
|
||||
};
|
||||
|
||||
// Walk the results the way the consumers do and collect the rows that survive.
|
||||
std::vector<Row> rows(const std::vector<Entry> &results) {
|
||||
std::vector<Row> out;
|
||||
for (size_t i = 0; i < results.size(); i++) {
|
||||
bool with_auth = false;
|
||||
if (!should_show_scan_entry(results, results[i], with_auth))
|
||||
continue;
|
||||
out.push_back({results[i].ssid, results[i].rssi, with_auth});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ScanList, SingleEntryShown) {
|
||||
std::vector<Entry> results = {{"Home", -60}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}}));
|
||||
}
|
||||
|
||||
TEST(ScanList, DistinctSsidsAllShownInOrder) {
|
||||
std::vector<Entry> results = {{"Home", -60}, {"Guest", -70}, {"Cafe", -40}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}, {"Guest", -70, true}, {"Cafe", -40, true}}));
|
||||
}
|
||||
|
||||
// Results are ordered by connection preference, not RSSI, so the strongest entry
|
||||
// can sit anywhere in the list.
|
||||
TEST(ScanList, SameSsidKeepsStrongest) {
|
||||
std::vector<Entry> results = {{"Home", -70}, {"Home", -50}, {"Home", -60}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -50, true}}));
|
||||
}
|
||||
|
||||
TEST(ScanList, EqualRssiKeepsFirst) {
|
||||
std::vector<Entry> results = {{"Home", -60}, {"Home", -60}, {"Home", -60}};
|
||||
bool with_auth = false;
|
||||
EXPECT_TRUE(should_show_scan_entry(results, results[0], with_auth));
|
||||
EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth));
|
||||
EXPECT_FALSE(should_show_scan_entry(results, results[2], with_auth));
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}}));
|
||||
}
|
||||
|
||||
// with_auth is an out-parameter that must only be written for a shown entry.
|
||||
TEST(ScanList, WithAuthUntouchedWhenNotShown) {
|
||||
std::vector<Entry> results = {{"Home", -50, false}, {"Home", -70, true}};
|
||||
bool with_auth = false;
|
||||
EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth));
|
||||
EXPECT_FALSE(with_auth);
|
||||
}
|
||||
|
||||
TEST(ScanList, DuplicatesInterleavedWithOtherNetworks) {
|
||||
std::vector<Entry> results = {{"Home", -70}, {"Guest", -55}, {"Home", -50}, {"Guest", -65}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Guest", -55, true}, {"Home", -50, true}}));
|
||||
}
|
||||
|
||||
// Hidden networks scan with an empty SSID. They are never listed and do not
|
||||
// collapse into each other or into anything else.
|
||||
TEST(ScanList, HiddenEntriesNeverShown) {
|
||||
std::vector<Entry> results = {{"", -40, true, true}, {"Home", -70}, {"", -30, true, true}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -70, true}}));
|
||||
}
|
||||
|
||||
// On ESP8266 the hidden flag comes from the driver alongside a real SSID, so a
|
||||
// hidden access point can share its name with a visible one. It must not
|
||||
// outrank that visible entry and leave the network unlisted.
|
||||
TEST(ScanList, HiddenEntryDoesNotSuppressVisibleSameSsid) {
|
||||
std::vector<Entry> results = {{"Home", -40, true, true}, {"Home", -70}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -70, true}}));
|
||||
}
|
||||
|
||||
// An open access point and a secured one sharing an SSID collapse to one row that
|
||||
// still asks for a password, whichever of them is strongest.
|
||||
TEST(ScanList, LockSetWhenAnyEntryRequiresAuth) {
|
||||
std::vector<Entry> open_stronger = {{"Home", -50, false}, {"Home", -70, true}};
|
||||
EXPECT_EQ(rows(open_stronger), (std::vector<Row>{{"Home", -50, true}}));
|
||||
|
||||
std::vector<Entry> secured_stronger = {{"Home", -70, false}, {"Home", -50, true}};
|
||||
EXPECT_EQ(rows(secured_stronger), (std::vector<Row>{{"Home", -50, true}}));
|
||||
}
|
||||
|
||||
TEST(ScanList, LockClearWhenEveryEntryIsOpen) {
|
||||
std::vector<Entry> results = {{"Cafe", -60, false}, {"Cafe", -50, false}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Cafe", -50, false}}));
|
||||
}
|
||||
|
||||
// The auth flag of an unrelated network must not leak into another SSID's row.
|
||||
TEST(ScanList, LockIsPerSsid) {
|
||||
std::vector<Entry> results = {{"Cafe", -60, false}, {"Home", -50, true}};
|
||||
EXPECT_EQ(rows(results), (std::vector<Row>{{"Cafe", -60, false}, {"Home", -50, true}}));
|
||||
}
|
||||
|
||||
TEST(ScanList, EmptyListShowsNothing) {
|
||||
std::vector<Entry> results;
|
||||
EXPECT_TRUE(rows(results).empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::wifi::testing
|
||||
@@ -29,6 +29,7 @@ void setup() {
|
||||
|
||||
auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT
|
||||
ota->set_port(8266);
|
||||
App.register_component_(ota);
|
||||
|
||||
App.setup();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
|
||||
- `conftest.py` - Common fixtures and utilities
|
||||
- `const.py` - Constants used throughout the integration tests
|
||||
- `types.py` - Type definitions for fixtures and functions
|
||||
- `raw_api_client.py` - Minimal plaintext api client whose reads happen only on request (for backpressure tests)
|
||||
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
|
||||
- `fixtures/` - YAML configuration files for tests
|
||||
- `test_*.py` - Individual test files
|
||||
@@ -347,6 +348,7 @@ Create C++ components in `fixtures/external_components/` for:
|
||||
- Custom entity behaviors
|
||||
- Scheduler testing
|
||||
- Memory management tests
|
||||
- Deterministic network backpressure (`sndbuf_pin_component` pins socket send buffers; assert on its log line to prove the pin took effect)
|
||||
|
||||
##### Log Line Monitoring
|
||||
```python
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: get-time-tz-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
time:
|
||||
- platform: homeassistant
|
||||
id: ha_time
|
||||
|
||||
sensor:
|
||||
# Exposes the standard offset of the effective timezone so the test can
|
||||
# observe which GetTimeResponse messages changed it
|
||||
- platform: template
|
||||
name: "TZ Offset"
|
||||
id: tz_offset
|
||||
accuracy_decimals: 0
|
||||
update_interval: 100ms
|
||||
lambda: |-
|
||||
return time::get_global_tz().std_offset_seconds;
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: api-backpressure-test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
# Smallest queue so a non-draining client blocks the send path quickly
|
||||
max_send_queue: 1
|
||||
actions:
|
||||
# GENERATED_ACTIONS
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
components: [sndbuf_pin_component]
|
||||
|
||||
# Pins the device's socket send buffers for deterministic TCP backpressure
|
||||
sndbuf_pin_component:
|
||||
buffer_size: SERVER_SNDBUF
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,20 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID
|
||||
|
||||
DEPENDENCIES = ["api"]
|
||||
|
||||
sndbuf_pin_ns = cg.esphome_ns.namespace("sndbuf_pin")
|
||||
SndbufPinComponent = sndbuf_pin_ns.class_("SndbufPinComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SndbufPinComponent),
|
||||
cv.Required(CONF_BUFFER_SIZE): cv.int_range(min=1),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_BUFFER_SIZE])
|
||||
await cg.register_component(var, config)
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#include "sndbuf_pin_component.h"
|
||||
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
static const char *const TAG = "sndbuf_pin";
|
||||
|
||||
// Skip stdio; scan the low fd range where the listeners land
|
||||
static constexpr int FIRST_USER_FD = 3;
|
||||
static constexpr int MAX_FD_SCAN = 128;
|
||||
|
||||
void SndbufPinComponent::setup() {
|
||||
int pinned = 0;
|
||||
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
|
||||
int type = 0;
|
||||
socklen_t len = sizeof(type);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
|
||||
continue;
|
||||
struct sockaddr_in addr {};
|
||||
socklen_t addr_len = sizeof(addr);
|
||||
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
|
||||
continue;
|
||||
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
int applied = 0;
|
||||
len = sizeof(applied);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
|
||||
// Linux doubles the requested value; anything below it means clamped
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
|
||||
continue;
|
||||
}
|
||||
// Tests assert on this line; accepted sockets inherit the pinned size
|
||||
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
|
||||
applied);
|
||||
pinned++;
|
||||
}
|
||||
if (pinned == 0) {
|
||||
ESP_LOGE(TAG, "api listener socket was not pinned");
|
||||
this->mark_failed();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
// Test-only (host): pins SO_SNDBUF on every open TCP socket so integration
|
||||
// tests get deterministic backpressure; an explicit SO_SNDBUF also disables
|
||||
// kernel autotuning, and accepted sockets inherit it from the listener.
|
||||
class SndbufPinComponent : public Component {
|
||||
public:
|
||||
explicit SndbufPinComponent(int buffer_size) : buffer_size_(buffer_size) {}
|
||||
void setup() override;
|
||||
// After the api server so its listening socket exists
|
||||
float get_setup_priority() const override { return setup_priority::LATE; }
|
||||
|
||||
protected:
|
||||
int buffer_size_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
@@ -0,0 +1,41 @@
|
||||
esphome:
|
||||
name: test_suspend_resume_device
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
|
||||
preferences:
|
||||
id: prefs_syncer
|
||||
flash_write_interval: 1s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Save Preference"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// save() only updates the in-memory map; only sync() persists it to disk.
|
||||
ESPPreferenceObject pref = global_preferences->make_preference<uint32_t>(0xBEEF);
|
||||
uint32_t value = 123;
|
||||
if (pref.save(&value)) {
|
||||
ESP_LOGI("test", "Preference saved in memory");
|
||||
} else {
|
||||
ESP_LOGE("test", "Preference save failed");
|
||||
}
|
||||
|
||||
- platform: template
|
||||
name: "Suspend Syncer"
|
||||
on_press:
|
||||
- component.suspend: prefs_syncer
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Syncer suspended");
|
||||
|
||||
- platform: template
|
||||
name: "Resume Syncer"
|
||||
on_press:
|
||||
- component.resume: prefs_syncer
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Syncer resumed");
|
||||
@@ -0,0 +1,28 @@
|
||||
esphome:
|
||||
name: online-image-bmp
|
||||
|
||||
host:
|
||||
|
||||
http_request:
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
format: AUTO
|
||||
id: myimg
|
||||
type: RGB
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished. cache hit: %u"
|
||||
args: [cached]
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: fetch_image
|
||||
then:
|
||||
- component.update: myimg
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,28 @@
|
||||
esphome:
|
||||
name: online-image-bmp
|
||||
|
||||
host:
|
||||
|
||||
http_request:
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
id: myimg
|
||||
format: AUTO
|
||||
type: RGB
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished. cache hit: %u"
|
||||
args: [cached]
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: fetch_image
|
||||
then:
|
||||
- component.update: myimg
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -7,8 +7,9 @@ http_request:
|
||||
|
||||
display:
|
||||
|
||||
online_image:
|
||||
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
image:
|
||||
- platform: online_image
|
||||
url: http://127.0.0.1:HTTP_PORT/foo.bmp
|
||||
id: myimg
|
||||
format: BMP
|
||||
type: RGB
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-continuous
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
|
||||
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
|
||||
globals:
|
||||
- id: silence_server
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(silence_server);"
|
||||
then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
|
||||
# replies arrive within turnaround_time, so this does not slow the streaming path.
|
||||
send_wait_time: 100ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
# A long update_interval means that without continuous polling only the boot poll would run in the
|
||||
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
|
||||
# bus allows.
|
||||
update_interval: 30s
|
||||
continuous: true
|
||||
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
|
||||
max_cmd_retries: 1
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
# Each read returns the next counter value, so every poll publishes a distinct state the test can
|
||||
# count (proving the read actually ran, not just that the state changed once).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
static uint16_t counter = 0;
|
||||
return counter++;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "continuous_reg"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
|
||||
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
|
||||
on_press:
|
||||
- lambda: "id(modbus_controller_1)->update();"
|
||||
|
||||
switch:
|
||||
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
|
||||
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
|
||||
- platform: template
|
||||
name: "Silence Server"
|
||||
id: silence_server_switch
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- lambda: "id(silence_server) = true;"
|
||||
turn_off_action:
|
||||
- lambda: "id(silence_server) = false;"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user