mirror of
https://github.com/esphome/esphome.git
synced 2026-09-25 14:00:25 +00:00
Merge branch 'dev' into frenck/replace-voluptuous-with-probatio
This commit is contained in:
@@ -194,4 +194,40 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) {
|
||||
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
|
||||
}
|
||||
|
||||
// --- registers_to_number ---------------------------------------------------
|
||||
// Register words are host byte order; results must match the byte-based payload_to_number.
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) {
|
||||
const uint16_t registers[] = {0x1234};
|
||||
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234);
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) {
|
||||
const uint16_t registers[] = {0x1234, 0x5678};
|
||||
EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678);
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) {
|
||||
// The function decodes the value at the start of the span; the caller advances the pointer.
|
||||
const uint16_t registers[] = {0xAAAA, 0x1234};
|
||||
EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234);
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
|
||||
// Same value via both decoders: registers (host order) vs big-endian bytes.
|
||||
const uint16_t registers[] = {0x8001, 0x0002};
|
||||
const std::vector<uint8_t> bytes{0x80, 0x01, 0x00, 0x02};
|
||||
for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) {
|
||||
EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF))
|
||||
<< "value_type=" << static_cast<int>(value_type);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
|
||||
const uint16_t registers[] = {0x1234};
|
||||
bool error = false;
|
||||
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0);
|
||||
EXPECT_TRUE(error);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::helpers
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/modbus_server/modbus_server.h"
|
||||
|
||||
namespace esphome::modbus_server {
|
||||
|
||||
using modbus::ModbusExceptionCode;
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// A single writable WORD register is applied and the handler reports success (nullopt).
|
||||
TEST(ModbusServerWrite, SingleWordSucceeds) {
|
||||
ModbusServer server;
|
||||
int64_t written = -1;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
reg.write_lambda = [&written](int64_t value) {
|
||||
written = value;
|
||||
return true;
|
||||
};
|
||||
server.add_server_register(®);
|
||||
|
||||
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
|
||||
EXPECT_FALSE(status.has_value()); // nullopt == success
|
||||
EXPECT_EQ(written, 0x1234);
|
||||
}
|
||||
|
||||
// A multi-register value is decoded high word first and applied as a single number.
|
||||
TEST(ModbusServerWrite, DwordSucceeds) {
|
||||
ModbusServer server;
|
||||
int64_t written = -1;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
|
||||
reg.write_lambda = [&written](int64_t value) {
|
||||
written = value;
|
||||
return true;
|
||||
};
|
||||
server.add_server_register(®);
|
||||
|
||||
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678}));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_EQ(written, 0x12345678);
|
||||
}
|
||||
|
||||
// Regression: a request that under-supplies a multi-register value is rejected before any
|
||||
// write_lambda runs, so no register is partially written.
|
||||
TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
|
||||
ModbusServer server;
|
||||
bool word_written = false;
|
||||
ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
word_reg.write_lambda = [&word_written](int64_t) {
|
||||
word_written = true;
|
||||
return true;
|
||||
};
|
||||
bool dword_written = false;
|
||||
ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers
|
||||
dword_reg.write_lambda = [&dword_written](int64_t) {
|
||||
dword_written = true;
|
||||
return true;
|
||||
};
|
||||
server.add_server_register(&word_reg);
|
||||
server.add_server_register(&dword_reg);
|
||||
|
||||
// Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs.
|
||||
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied
|
||||
EXPECT_FALSE(dword_written);
|
||||
}
|
||||
|
||||
// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing.
|
||||
TEST(ModbusServerWrite, UnwritableRegisterRejected) {
|
||||
ModbusServer server;
|
||||
ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set
|
||||
server.add_server_register(&read_only);
|
||||
|
||||
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
|
||||
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
|
||||
ModbusServer server;
|
||||
auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
|
||||
// applied, and the handler reports SERVICE_DEVICE_FAILURE.
|
||||
TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
|
||||
ModbusServer server;
|
||||
bool first_written = false;
|
||||
ServerRegister first(0x0000, SensorValueType::U_WORD, 1);
|
||||
first.write_lambda = [&first_written](int64_t) {
|
||||
first_written = true;
|
||||
return true;
|
||||
};
|
||||
ServerRegister second(0x0001, SensorValueType::U_WORD, 1);
|
||||
second.write_lambda = [](int64_t) { return false; }; // rejects at runtime
|
||||
server.add_server_register(&first);
|
||||
server.add_server_register(&second);
|
||||
|
||||
auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
|
||||
EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_server
|
||||
@@ -1,56 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-bcast
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
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
|
||||
|
||||
# No on_tx injection: a broadcast (address 0) gets no reply on a real bus.
|
||||
uart_mock:
|
||||
- id: virtual_uart
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart
|
||||
id: virtual_modbus
|
||||
role: client
|
||||
send_wait_time: 200ms
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 0
|
||||
modbus_id: virtual_modbus
|
||||
update_interval: 60s
|
||||
id: modbus_controller_bcast
|
||||
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_bcast
|
||||
id: bcast_write
|
||||
name: "bcast_write"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 65535
|
||||
|
||||
interval:
|
||||
- interval: 400ms
|
||||
then:
|
||||
- number.set:
|
||||
id: bcast_write
|
||||
value: 42
|
||||
@@ -19,7 +19,6 @@ from aioesphomeapi import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=EntityInfo)
|
||||
S = TypeVar("S", bound=EntityState)
|
||||
|
||||
|
||||
@@ -58,7 +57,7 @@ async def wait_for_state(
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
|
||||
|
||||
def find_entity(
|
||||
def find_entity[T: EntityInfo](
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
entity_type: type[T] | None = None,
|
||||
@@ -86,7 +85,7 @@ def find_entity(
|
||||
return None
|
||||
|
||||
|
||||
def require_entity(
|
||||
def require_entity[T: EntityInfo](
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
entity_type: type[T] | None = None,
|
||||
|
||||
@@ -330,28 +330,3 @@ async def test_uart_mock_modbus_server_controller_multiple(
|
||||
await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_all(futures)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_broadcast(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that broadcast writes (address 0) don't wait for a response.
|
||||
|
||||
A controller at address 0 sends broadcast writes that get no reply. The
|
||||
client must not arm the response timeout for them: otherwise every write
|
||||
blocks for send_wait_time and logs a spurious "no response from 0" warning.
|
||||
"""
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected(),
|
||||
):
|
||||
# Several broadcast writes fire on the 400ms interval; send_wait_time is
|
||||
# 200ms, so the old behaviour would have warned on each one by now.
|
||||
await asyncio.sleep(3.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for esphome.config module."""
|
||||
|
||||
from collections.abc import Generator
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
@@ -113,3 +114,57 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
|
||||
platforms = {p.get("platform") for p in result["ota"]}
|
||||
assert "esphome" in platforms, f"Expected esphome platform in {platforms}"
|
||||
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
|
||||
|
||||
|
||||
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
|
||||
"""Create a config where two `<<` includes both define `logger:`.
|
||||
|
||||
The second `logger:` is dropped by the shallow merge. Returns the main file.
|
||||
"""
|
||||
(tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n")
|
||||
(tmp_path / "b.yaml").write_text("logger:\n level: INFO\n")
|
||||
esphome_section = "esphome:\n name: test\n"
|
||||
if suppress:
|
||||
esphome_section += " merge_warnings: false\n"
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n")
|
||||
return main
|
||||
|
||||
|
||||
def test_validate_config_warns_on_dropped_merge_key(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""By default, a `<<` merge that drops a key logs a warning."""
|
||||
main = _write_merge_conflict_config(tmp_path, suppress=False)
|
||||
CORE.config_path = main
|
||||
raw_config = yaml_util.load_yaml(main)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config"):
|
||||
config.validate_config(raw_config, {})
|
||||
|
||||
assert any(
|
||||
"was dropped while processing a '<<' merge" in record.message
|
||||
and "logger" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
# The queue is drained so the warning cannot leak into a later run.
|
||||
assert yaml_util.take_dropped_merge_keys() == []
|
||||
|
||||
|
||||
def test_validate_config_suppresses_merge_warning(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""`esphome: merge_warnings: false` hides the warning but still drains the queue."""
|
||||
main = _write_merge_conflict_config(tmp_path, suppress=True)
|
||||
CORE.config_path = main
|
||||
raw_config = yaml_util.load_yaml(main)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.config"):
|
||||
config.validate_config(raw_config, {})
|
||||
|
||||
assert not any(
|
||||
"was dropped while processing a '<<' merge" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
# The queue is drained even when the warning is suppressed.
|
||||
assert yaml_util.take_dropped_merge_keys() == []
|
||||
|
||||
@@ -658,11 +658,6 @@ def test_get_python_env_executable_path_nt() -> None:
|
||||
|
||||
|
||||
class TestTarExtractAllBranches:
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 12),
|
||||
reason="patching os.name makes pathlib build a WindowsPath, which only "
|
||||
"instantiates on POSIX in 3.12+",
|
||||
)
|
||||
def test_windows_drive_path_skipped(self, tmp_path: Path) -> None:
|
||||
"""Windows-style drive path (C:/...) is skipped when os.name == 'nt'."""
|
||||
info = tarfile.TarInfo(name="C:/secret.txt")
|
||||
@@ -755,11 +750,6 @@ class TestTarExtractAllBranches:
|
||||
|
||||
|
||||
class TestZipExtractAllBranches:
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 12),
|
||||
reason="patching os.name makes pathlib build a WindowsPath, which only "
|
||||
"instantiates on POSIX in 3.12+",
|
||||
)
|
||||
def test_windows_drive_path_skipped(self, tmp_path: Path) -> None:
|
||||
"""Windows-style drive path (C:/...) is skipped when os.name == 'nt'."""
|
||||
buf = _make_zip([("C:/secret.txt", "bad")])
|
||||
|
||||
@@ -1395,3 +1395,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None:
|
||||
assert "\\033[8m" in redacted
|
||||
assert "\\033[8m" not in raw
|
||||
assert "\\033[8m" in redacted_again
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_dropped_merge_keys() -> None:
|
||||
"""Reset the dropped-merge-key queue between tests."""
|
||||
core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None)
|
||||
yield
|
||||
core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None)
|
||||
|
||||
|
||||
def test_merge_include_records_dropped_keys(tmp_path: Path) -> None:
|
||||
"""A `<<` merge that overlaps an existing key records it (shallow first-wins)."""
|
||||
(tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n")
|
||||
(tmp_path / "b.yaml").write_text("api:\n password: secret\n")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n")
|
||||
|
||||
with patch.object(core.CORE, "config_path", test_yaml):
|
||||
result = yaml_util.load_yaml(test_yaml)
|
||||
|
||||
# First definition wins; the second `api` block is dropped entirely.
|
||||
assert result["api"] == {"reboot_timeout": "5min"}
|
||||
|
||||
dropped = yaml_util.take_dropped_merge_keys()
|
||||
assert len(dropped) == 1
|
||||
key, location = dropped[0]
|
||||
assert key == "api"
|
||||
assert "b.yaml" in location
|
||||
# Queue is drained after being taken.
|
||||
assert yaml_util.take_dropped_merge_keys() == []
|
||||
|
||||
|
||||
def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None:
|
||||
"""A `<<` merge with distinct top-level keys drops nothing."""
|
||||
(tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n")
|
||||
(tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n")
|
||||
test_yaml = tmp_path / "test.yaml"
|
||||
test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n")
|
||||
|
||||
with patch.object(core.CORE, "config_path", test_yaml):
|
||||
result = yaml_util.load_yaml(test_yaml)
|
||||
|
||||
assert result["api"] == {"reboot_timeout": "5min"}
|
||||
assert result["logger"] == {"level": "DEBUG"}
|
||||
assert yaml_util.take_dropped_merge_keys() == []
|
||||
|
||||
Reference in New Issue
Block a user