Merge branch 'dev' into rp2-3-connection-slots

This commit is contained in:
J. Nick Koston
2026-08-10 21:47:31 -05:00
committed by GitHub
72 changed files with 2864 additions and 435 deletions
@@ -7,8 +7,13 @@ from esphome.components.modbus_server import (
SERVER_SENSOR_VALUE_TYPE,
_validate_no_overlapping_registers,
_validate_register_ranges,
_validate_unique_bit_addresses,
)
from esphome.components.modbus_server.const import (
CONF_BITS,
CONF_REGISTERS,
CONF_VALUE_TYPE,
)
from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE
from esphome.const import CONF_ADDRESS
@@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict:
}
def _bits_config(addresses: list[int]) -> dict:
return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]}
def test_non_overlapping_registers_pass() -> None:
# Values that tile the address space without gaps or overlaps are accepted.
config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")])
@@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None:
_validate_no_overlapping_registers(config)
def test_unique_bit_addresses_pass() -> None:
config = _bits_config([0x00, 0x01, 0x02])
assert _validate_unique_bit_addresses(config) is config
def test_duplicate_bit_address_rejected() -> None:
# Coils and discrete inputs share one bit address space, so a repeated address is rejected.
config = _bits_config([0x05, 0x05])
with pytest.raises(cv.Invalid, match="more than once"):
_validate_unique_bit_addresses(config)
def test_multi_register_value_overlapping_neighbour_rejected() -> None:
# U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word.
config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")])
@@ -0,0 +1,8 @@
hoermann_hcp:
id: hoermann_hcp_hub
modbus_id: modbus_server_bus
cover:
- platform: hoermann_hcp
name: Garage Door
device_class: garage
@@ -0,0 +1,174 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h"
namespace esphome::hoermann_hcp {
using modbus::RegisterValues;
namespace {
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The door only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
} // namespace
// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish.
TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
int publishes = 0;
cover.add_on_state_callback([&publishes]() { publishes++; });
ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
// Any request marks the device connected, which is itself a state change.
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
door.update();
EXPECT_EQ(publishes, 1);
EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED);
}
// Venting and half-open moves report no direction, so one is only derived once the position has moved.
TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
// Position 100/200 = 0.5, state 0x80 -> resting half open.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000}));
door.update();
ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
// State 0x05 -> moving to half-open, but the position has not moved yet.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
// Position 120/200 = 0.6 is higher than before, so the door is opening.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING);
EXPECT_FLOAT_EQ(cover.position, 0.6f);
}
// Booting while the door is already mid-move gives no baseline to compare against, so no direction
// may be inferred from the first update.
TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
// The very first thing seen is a half-open move already at 100/200 = 0.5.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
door.update();
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
}
// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather
// than as a target the door would be stopped at.
TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_open().perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
}
// The same for cover.close, which arrives as a position of 0.0.
TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
}
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_command_toggle().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
// The door is opening, so it takes an impulse to stop it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
cover.make_call().set_command_stop().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// A position between the end stops starts the door in the right direction; it is stopped there later.
TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
HoermannHcp door; // starts out fully closed
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
cover.make_call().set_position(0.5f).perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
}
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
// to be published back over that assumption.
TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) {
HoermannHcp door; // never contacted by a bus controller
HoermannHcpCover cover(&door);
cover.setup();
int publishes = 0;
cover.add_on_state_callback([&publishes]() { publishes++; });
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(publishes, 1);
EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
}
// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would
// otherwise sit at its fully open default and look healthy.
TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
EXPECT_TRUE(cover.status_has_warning());
connect(door);
door.update();
EXPECT_FALSE(cover.status_has_warning());
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,430 @@
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
namespace esphome::hoermann_hcp {
using modbus::RegisterValues;
namespace {
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The device only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
class TestableHoermannHcp : public HoermannHcp {
public:
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
using HoermannHcp::set_valid_;
};
} // namespace
// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004.
TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 2, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 2u);
EXPECT_EQ(response[0], 0x0004);
EXPECT_EQ(response[1], 0x0000);
}
// A bus scan (write 3 / read 5) answers with the fixed device identification block.
TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 5, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 5u);
EXPECT_EQ(response[1], 0x0005);
EXPECT_EQ(response[2], 0x0430);
EXPECT_EQ(response[3], 0x10ff);
EXPECT_EQ(response[4], 0xa845);
}
// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press.
TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[1], 0x0001);
EXPECT_EQ(response[2], 0x0000);
EXPECT_EQ(response[3], 0x0000);
}
// A queued control command is injected into the next command poll as a simulated key press.
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
HoermannHcp door;
connect(door);
door.open_door();
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
EXPECT_EQ(response[3], 0x0000);
}
// A read of any other block is an addressing error rather than a successful all-zero reply.
TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
HoermannHcp door;
RegisterValues response;
EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A command is held for the key-press duration, then released, and only then can the next one be queued.
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
TestableHoermannHcp door;
connect(door);
door.open_door();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
// Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120.
door.close_door();
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// With the command gone, the next one is accepted again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
}
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) {
HoermannHcp door;
door.open_door();
EXPECT_EQ(poll_command(door), 0x0000);
}
// Losing the controller must drop a command it never fetched, otherwise it blocks every later command
// and fires unasked once the bus comes back.
TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
TestableHoermannHcp door;
connect(door);
door.open_door();
ASSERT_TRUE(door.is_valid());
door.set_valid_(false);
EXPECT_FALSE(door.is_valid());
// The reconnecting poll must not replay the dropped command.
EXPECT_EQ(poll_command(door), 0x0000);
// And the slot is free, so a new command is accepted.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
}
// The connection is dropped by update() once the controller stops polling, which is what releases a
// command it never fetched in the field.
TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
TestableHoermannHcp door;
// Wide enough that a stall cannot expire the connection before the check below runs.
door.connection_timeout_ms_ = 10000;
connect(door);
door.open_door();
// Still inside the window: the controller counts as present.
door.update();
ASSERT_TRUE(door.is_valid());
// Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer.
door.connection_timeout_ms_ = 20;
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.update();
EXPECT_FALSE(door.is_valid());
// The pending command went with the connection instead of firing on the reconnecting poll.
EXPECT_EQ(poll_command(door), 0x0000);
}
// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to
// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely.
TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
door.open_door();
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// A status broadcast refreshes the connection without ever fetching the command.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
door.update();
ASSERT_TRUE(door.is_valid());
// With the stale command gone, the door accepts commands again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
}
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
// differently per block length.
TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) {
HoermannHcp door;
// Counter 0x34 in the high byte, command 0x07 in the low byte.
door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000}));
RegisterValues command_poll;
door.on_read_holding_registers(STATE_REG, 8, command_poll);
ASSERT_EQ(command_poll.size(), 8u);
EXPECT_EQ(command_poll[0], 0x3400); // counter alone
EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low
RegisterValues empty_poll;
door.on_read_holding_registers(STATE_REG, 2, empty_poll);
ASSERT_EQ(empty_poll.size(), 2u);
EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here
EXPECT_EQ(empty_poll[1], 0x0700); // command alone
RegisterValues scan;
door.on_read_holding_registers(STATE_REG, 5, scan);
ASSERT_EQ(scan.size(), 5u);
EXPECT_EQ(scan[0], 0x3400);
EXPECT_EQ(scan[1], 0x0705);
}
// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position.
TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) {
HoermannHcp door;
// registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening).
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a
// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed.
TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) {
HoermannHcp door;
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(door.get_door_state(), DoorState::STOPPED);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of
// the state register still has to be decoded.
TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) {
HoermannHcp door;
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061}));
EXPECT_EQ(door.get_door_state(), DoorState::VENT);
}
// A door parking a count short of its end stop must still report exactly closed or open, because
// Cover::is_fully_closed() compares against 0.0 exactly.
TEST(HoermannHcpWrite, EndStopsReportExactPositions) {
HoermannHcp door;
// Position register 1 of 200 while the door reports itself closed.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f);
// Position register 199 of 200 while the door reports itself open.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000}));
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f);
// Away from the end stops the raw count is reported as-is.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
}
// A position request below the lower snap threshold becomes a plain close command.
TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
HoermannHcp door;
connect(door);
door.set_position(0.02f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value
}
// A half-open target starts the door moving towards the requested position.
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
HoermannHcp door; // starts out fully closed
connect(door);
door.set_position(0.5f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
}
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// Position 20/200 = 0.1 while opening: short of the target, so the door keeps going.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_EQ(poll_command(door), 0x0000);
// Position 120/200 = 0.6 is past the target, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
// must be read as "already stopped" rather than "still opening".
TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
// Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000}));
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
EXPECT_EQ(poll_command(door), 0x0000);
}
// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
TestableHoermannHcp door;
connect(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
// The door is stopped at 0.3 by a wall button, short of the requested 0.5.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
}
// A target armed while the door is still travelling the other way must not be judged by that old direction,
// otherwise the very next position it reports counts as reached and stops the door where it stands.
TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) {
TestableHoermannHcp door;
connect(door);
// The door is closing, passing 60/200 = 0.3.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
// Still closing at 58/200 = 0.29: below the target, but not on the way to it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200}));
EXPECT_EQ(poll_command(door), 0x0000);
// Now opening at 62/200 = 0.31, still short of the target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
// Past the target at 110/200 = 0.55, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
}
// A motor turning around can report a momentary stop; dropping the target there would let the door run on
// to the end stop that the reversing command asked for.
TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
TestableHoermannHcp door;
connect(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
// The stop reported on the way from closing to opening.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// The door then opens and still has to be stopped at the requested position.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240);
}
// A door that never turns around has to lose the target as well, otherwise it would cut a later move short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the
// target is the only thing that may expire here.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
door.update();
ASSERT_TRUE(door.is_valid());
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
hoermann_hcp: !include common.yaml
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml
hoermann_hcp: !include common.yaml
@@ -2,16 +2,16 @@
namespace esphome::mitsubishi_cn105::testing {
struct TestContext {
struct MitsubishiCN105TestsContext {
MockUARTComponent uart;
uart::UARTDevice device{&uart};
TestableMitsubishiCN105 sut{device};
TestContext() { this->sut.set_current_time(0); }
MitsubishiCN105TestsContext() { this->sut.set_current_time(0); }
};
TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_current_time(123);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED);
@@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
}
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
@@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
@@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
}
TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
@@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
}
TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
@@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) {
}
TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(80000);
@@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55});
@@ -266,14 +266,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
ctx.sut.update();
EXPECT_TRUE(ctx.sut.status().power_on);
EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_);
EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b);
EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET);
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD});
@@ -281,14 +281,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) {
ctx.sut.update();
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_);
EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b);
EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4);
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D});
@@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) {
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7});
@@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
}
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58});
@@ -316,11 +316,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) {
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_);
EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit);
}
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8});
@@ -328,11 +328,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) {
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_);
EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit);
}
TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_power(true);
ctx.sut.apply_settings();
@@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
}
TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_target_temperature(23.0f);
ctx.sut.apply_settings();
@@ -352,9 +352,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) {
}
TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.property_context_.use_temperature_encoding_b = true;
ctx.sut.set_target_temperature(26.0f);
ctx.sut.apply_settings();
@@ -363,9 +363,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) {
}
TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.property_context_.use_temperature_encoding_b = true;
ctx.sut.set_target_temperature(26.5f);
ctx.sut.apply_settings();
@@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) {
}
TEST(MitsubishiCN105Tests, ApplyModeCool) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_mode(MitsubishiCN105::Mode::COOL);
ctx.sut.apply_settings();
@@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) {
}
TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1);
ctx.sut.apply_settings();
@@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
}
TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING);
ctx.sut.apply_settings();
@@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) {
}
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
ctx.sut.apply_settings();
@@ -414,9 +414,9 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) {
}
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_wide_vane_high_bit_ = true;
ctx.sut.property_context_.set_wide_vane_high_bit = true;
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
ctx.sut.apply_settings();
@@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) {
}
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(5000);
@@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
// Write new values
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.property_context_.use_temperature_encoding_b = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
@@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
}
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
// Set remote temperature
ctx.sut.set_remote_temperature(28.5f);
@@ -505,10 +505,10 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
}
TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
// Queue normal settings plus remote temperature together.
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.property_context_.use_temperature_encoding_b = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
@@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN));
// ACK the first write. Remote temperature should still be pending afterward.
ctx.uart.tx.clear();
@@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
// The next apply sends the remote-temperature packet and clears the last pending flag.
ctx.uart.tx.clear();
@@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
}
TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(5000);
@@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
// Interrupt that wait with a write so credit is accumulated.
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.property_context_.use_temperature_encoding_b = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
@@ -578,28 +578,28 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
}
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_remote_temperature(7.0f);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
ctx.sut.set_remote_temperature(40.0f);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
ctx.sut.set_remote_temperature(NAN);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
}
TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_remote_temperature(8.0f);
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
}
TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) {
auto ctx = TestContext{};
MitsubishiCN105TestsContext ctx;
ctx.sut.set_remote_temperature(39.5f);
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE));
}
} // namespace esphome::mitsubishi_cn105::testing
+2 -3
View File
@@ -47,12 +47,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
public:
using MitsubishiCN105::MitsubishiCN105;
using MitsubishiCN105::State;
using MitsubishiCN105::UpdateFlag;
using MitsubishiCN105::PropertyId;
using MitsubishiCN105::state_;
using MitsubishiCN105::status_;
using MitsubishiCN105::operation_start_ms_;
using MitsubishiCN105::use_temperature_encoding_b_;
using MitsubishiCN105::set_wide_vane_high_bit_;
using MitsubishiCN105::property_context_;
using MitsubishiCN105::status_update_wait_credit_ms_;
using MitsubishiCN105::pending_updates_;
@@ -10,6 +10,12 @@ climate:
name: "AC Test"
supported_swing_modes: BOTH
select:
- platform: mitsubishi_cn105
mitsubishi_cn105_id: ac
vertical_vane_direction:
name: "Vertical Vane"
esphome:
on_boot:
then:
@@ -0,0 +1,111 @@
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h"
namespace esphome::mitsubishi_cn105::testing {
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
public:
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
void notify_status() { this->status_callback_.call(); }
};
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
public:
using MitsubishiCN105VerticalVaneDirectionSelect::control;
};
struct VerticalVaneDirectionSelectTestContext {
TestableMitsubishiCN105Component hub;
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
VerticalVaneDirectionSelectTestContext() {
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
this->select.set_parent(&this->hub);
this->select.setup();
}
};
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
constexpr std::array expected_modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3,
MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5,
MitsubishiCN105::VaneMode::SWING,
};
for (size_t i = 0; i < expected_modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.select.control(i);
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
}
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
constexpr std::array modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3,
MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5,
MitsubishiCN105::VaneMode::SWING,
};
for (size_t i = 0; i < modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.hub.mutable_status().vane_mode = modes[i];
ctx.hub.notify_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
}
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
ctx.hub.notify_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) {
VerticalVaneDirectionSelectTestContext ctx;
MitsubishiCN105Climate climate_entity;
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
climate_entity.setup();
ctx.select.control(6);
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);
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) {
VerticalVaneDirectionSelectTestContext ctx;
MitsubishiCN105Climate climate_entity;
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
climate_entity.setup();
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{0});
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.select.control(3);
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
EXPECT_FALSE(ctx.select.has_state());
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -15,6 +15,16 @@ modbus_server:
- id: modbus_server3
address: 0x3
modbus_id: mod_bus2
bits:
- address: 0x0
read_lambda: |-
return true;
- address: 0x1
read_lambda: |-
return address == 0x1;
write_lambda: |-
printf("bit address=%d, value=%d\n", (int) address, (int) x);
return true;
registers:
- address: 0x9
value_type: S_DWORD
@@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
// A write to an address not covered by any configured register (on a populated server) yields
// ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.write_lambda = [](int64_t) { return true; };
server.add_server_register(&reg);
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION.
TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) {
ModbusServer server;
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
}
// 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) {
@@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
EXPECT_EQ(out[1], 0xABCD);
}
// An unregistered address with courtesy disabled is rejected.
// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.read_lambda = []() -> int64_t { return 0x1234; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
@@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A server with no registers configured (courtesy disabled) does not implement the register-read
// function: ILLEGAL_FUNCTION.
TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) {
ModbusServer server;
RegisterValues out;
auto status = server.on_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
}
// A register read lambda returning an empty optional declines the read: the whole request is
// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda<T> so the optional-forwarding wrapper
// (not a hand-assigned read_lambda) is what carries the decline through.
TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.set_read_lambda<uint16_t>([](uint16_t address) -> optional<uint16_t> { return {}; });
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_read_registers(0x0000, 1, out);
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
}
// --- partial reads (opt-in) ----------------------------------------------------
// With allow_partial_read, reading only the first register of a DWORD returns its high word.
@@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) {
EXPECT_EQ(second[0], 0x1234);
}
// --- bits (coils / discrete inputs, one shared address space) -------------------
// Bits are read through the shared table regardless of which read function code arrived:
// the hub routes both 0x01 and 0x02 to on_read_bits().
TEST(ModbusServerBits, ReadSetsRequestedBits) {
ModbusServer server;
ServerBit bit0(0x0000);
bit0.set_read_lambda([](uint16_t) { return true; });
ServerBit bit1(0x0001);
bit1.set_read_lambda([](uint16_t) { return false; });
ServerBit bit2(0x0002);
bit2.set_read_lambda([](uint16_t) { return true; });
server.add_server_bit(&bit0);
server.add_server_bit(&bit1);
server.add_server_bit(&bit2);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(packed[0], 0b101);
}
// The read lambda receives the bit's address, so one lambda can serve several bits.
TEST(ModbusServerBits, ReadLambdaReceivesAddress) {
ModbusServer server;
ServerBit server_bit(0x0007);
server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; });
server.add_server_bit(&server_bit);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(packed[0], 0x01);
}
// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerBits, UnreadableBitRejectsRead) {
ModbusServer server;
ServerBit readable(0x0000);
readable.set_read_lambda([](uint16_t) { return true; });
ServerBit write_only(0x0001);
write_only.set_write_lambda([](uint16_t, bool) { return true; });
server.add_server_bit(&readable);
server.add_server_bit(&write_only);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1));
EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A read lambda returning an empty optional declines the read: the whole request is answered
// with SERVICE_DEVICE_FAILURE.
TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) {
ModbusServer server;
ServerBit ok(0x0000);
ok.set_read_lambda([](uint16_t) { return true; });
ServerBit declining(0x0001);
declining.set_read_lambda([](uint16_t) -> optional<bool> { return {}; });
server.add_server_bit(&ok);
server.add_server_bit(&declining);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
}
// A multi-coil write applies every bit and reports success.
TEST(ModbusServerBits, WriteAppliesAllBits) {
ModbusServer server;
bool state[2] = {false, true};
ServerBit bit0(0x0000);
bit0.set_write_lambda([&state](uint16_t, bool value) {
state[0] = value;
return true;
});
ServerBit bit1(0x0001);
bit1.set_write_lambda([&state](uint16_t, bool value) {
state[1] = value;
return true;
});
server.add_server_bit(&bit0);
server.add_server_bit(&bit1);
const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_FALSE(status.has_value());
EXPECT_TRUE(state[0]);
EXPECT_FALSE(state[1]);
}
// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any
// bit is applied.
TEST(ModbusServerBits, UnwritableBitAppliesNothing) {
ModbusServer server;
bool written = false;
ServerBit writable(0x0000);
writable.set_write_lambda([&written](uint16_t, bool) {
written = true;
return true;
});
ServerBit read_only(0x0001);
read_only.set_read_lambda([](uint16_t) { return false; });
server.add_server_bit(&writable);
server.add_server_bit(&read_only);
const uint8_t packed[1] = {0b11};
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(written); // the writable bit must NOT have been applied
}
// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and
// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior).
TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) {
ModbusServer server;
bool first_written = false;
ServerBit first(0x0000);
first.set_write_lambda([&first_written](uint16_t, bool) {
first_written = true;
return true;
});
ServerBit second(0x0001);
second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime
server.add_server_bit(&first);
server.add_server_bit(&second);
const uint8_t packed[1] = {0b11};
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_TRUE(first_written);
}
} // namespace esphome::modbus_server
@@ -133,8 +133,8 @@ button:
on_error:
then:
- lambda: "id(error_code).publish_state((int) exception_code);"
# The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read
# action's request PDU and its typed error delivery.
# The mock server maps no bits, so it does not implement the coil function: a coil read draws
# ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery.
- modbus_client.read_coils:
address: 1
start_address: 0x00
@@ -166,7 +166,7 @@ button:
on_not_sent:
then:
- lambda: "id(not_sent_flag).publish_state(1);"
# Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION.
# Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION.
- modbus_client.write_multiple_coils:
address: 1
start_address: 0x00
@@ -0,0 +1,147 @@
esphome:
name: uart-mock-modbus-srv-bits
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
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_bit_2
type: bool
initial_value: "false"
- id: stored_bit_3
type: bool
initial_value: "true"
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
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
# (FC 0x02): the server serves both from one shared bit table, so the two
# views must always agree.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_0"
address: 0x00
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_1"
address: 0x01
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_3"
address: 0x03
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_3"
address: 0x03
register_type: discrete_input
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
+7 -3
View File
@@ -387,8 +387,9 @@ class SensorStateCollector:
class SensorTracker:
"""Data-driven sensor state tracker with expected-value futures.
Tracks sensor state updates and resolves futures when sensors report
specific expected values. Eliminates per-sensor future boilerplate.
Tracks sensor and binary sensor state updates and resolves futures when
they report specific expected values. Eliminates per-sensor future
boilerplate.
Usage::
@@ -421,7 +422,10 @@ class SensorTracker:
def on_state(self, state: EntityState) -> None:
"""State callback suitable for ``subscribe_states``."""
if not isinstance(state, SensorState) or state.missing_state:
if (
not isinstance(state, (SensorState, BinarySensorState))
or state.missing_state
):
return
sensor_name = self.key_to_sensor.get(state.key)
if not sensor_name or sensor_name not in self.sensor_states:
+68 -5
View File
@@ -21,7 +21,7 @@ import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, NumberInfo
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .state_utils import SensorTracker, find_entity
@@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_bits(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test coil/discrete-input round trips between controller and server bits.
The server serves four bits from one shared table. The controller reads
each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02),
so the two views must always agree. Two bits are then written back, one
via the single-coil write (FC 0x05) and one via the multiple-coils write
(FC 0x0F), and the new values must show up in both read views.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
initial_values = {
"bit_coil_0": True,
"bit_coil_1": False,
"bit_coil_2": False,
"bit_coil_3": True,
"bit_di_0": True,
"bit_di_1": False,
"bit_di_2": False,
"bit_di_3": True,
}
tracker = SensorTracker(list(initial_values.keys()))
# Phase 1: expect initial baseline values in both read views
initial_futures = tracker.expect_all(initial_values)
# Phase 2: expect post-write values (registered now so on_state can match them)
written_futures = tracker.expect_all(
{
"bit_coil_2": True,
"bit_di_2": True,
"bit_coil_3": False,
"bit_di_3": False,
}
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
# Wait for initial baseline values to confirm the controller <-> server
# connection is working before issuing writes
await tracker.await_all(initial_futures, timeout=4.0)
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
entity = find_entity(entities, switch_name, SwitchInfo)
assert entity is not None, f"{switch_name} switch entity not found"
client.switch_command(entity.key, value)
# Wait for both read views to reflect the written values
await tracker.await_all(written_futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str,
@@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed(
with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value);
a read of unserved register 0x99 resolves via on_error with the device's exception code
(ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error
delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12
chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from
the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not
implement the coil function - proving the bit-read request and typed error delivery. A multi-register
write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler
(-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server
(-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime
builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent
(-> not_sent_flag).
"""
+4 -1
View File
@@ -31,11 +31,14 @@ common/
│ ├── esp32-c3-idf.yaml
│ ├── esp8266-ard.yaml
│ └── rp2040-ard.yaml
├── modbus/ # Modbus (includes uart via packages)
├── modbus/ # Modbus client (includes uart via packages)
│ ├── esp32-idf.yaml
│ ├── esp32-c3-idf.yaml
│ ├── esp8266-ard.yaml
│ └── rp2040-ard.yaml
├── modbus_server/ # Modbus server (includes uart via packages)
│ ├── esp32-idf.yaml
│ └── esp8266-ard.yaml
└── ble/
├── esp32-idf.yaml
├── esp32-ard.yaml
@@ -0,0 +1,10 @@
# Common server-role Modbus configuration for ESP32 IDF tests
# Provides a shared Modbus bus that all Modbus server components can use
packages:
uart: !include ../uart/esp32-idf.yaml
modbus:
- id: modbus_server_bus
uart_id: uart_bus
role: server
@@ -0,0 +1,10 @@
# Common server-role Modbus configuration for ESP8266 Arduino tests
# Provides a shared Modbus bus that all Modbus server components can use
packages:
uart: !include ../uart/esp8266-ard.yaml
modbus:
- id: modbus_server_bus
uart_id: uart_bus
role: server