mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 17:00:21 +00:00
Merge branch 'dev' into rp2-3-connection-slots
This commit is contained in:
@@ -15,6 +15,7 @@ bk72xx:
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
continuous: false
|
||||
active: false
|
||||
on_ble_advertise:
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
|
||||
@@ -48,6 +48,8 @@ def test_trigger_codegen(
|
||||
# scan_parameters continuous: false reaches the YAML-mode setter, not the
|
||||
# runtime override.
|
||||
assert "->set_configured_continuous(false)" in main_cpp
|
||||
# active: false (non-default) flows through to the setter.
|
||||
assert "->set_scan_active(false)" in main_cpp
|
||||
# Constructor call, not just the declaration: the parent argument is what
|
||||
# registers the trigger as a listener.
|
||||
assert re.search(
|
||||
|
||||
@@ -16,8 +16,8 @@ from esphome.components.ln882h_ble_tracker import (
|
||||
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
|
||||
|
||||
|
||||
def _validate(**kwargs: str) -> dict:
|
||||
"""Run a scan_parameters config through a passive tracker's real schema."""
|
||||
def _validate(**kwargs: str | bool) -> dict:
|
||||
"""Run a scan_parameters config through the bk72xx tracker's real schema."""
|
||||
return BK72XX_SCHEMA(kwargs)
|
||||
|
||||
|
||||
@@ -48,11 +48,12 @@ def test_to_ble_units_truncates() -> None:
|
||||
|
||||
|
||||
def test_bk72xx_defaults_are_valid() -> None:
|
||||
"""bk72xx pins the BK reference rate: 100 ms interval, shared 30 ms window."""
|
||||
"""bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window —
|
||||
and exposes active (default on, like every active-capable tracker)."""
|
||||
config = _validate()
|
||||
assert to_ble_units(config["interval"]) == 160
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
assert "active" not in config
|
||||
assert config["active"] is True
|
||||
|
||||
|
||||
def test_esp32_defaults_are_valid() -> None:
|
||||
@@ -86,10 +87,9 @@ def test_esp32_active_can_disable() -> None:
|
||||
assert config["active"] is False
|
||||
|
||||
|
||||
def test_passive_schema_rejects_active_key() -> None:
|
||||
"""Trackers without active scan support must not silently accept the option."""
|
||||
with pytest.raises(cv.Invalid):
|
||||
_validate(active="true")
|
||||
def test_bk72xx_active_can_disable() -> None:
|
||||
config = _validate(active=False)
|
||||
assert config["active"] is False
|
||||
|
||||
|
||||
# --- accepted configurations ---
|
||||
|
||||
@@ -15,6 +15,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2,
|
||||
@@ -27,18 +28,20 @@ from ..types import SetCoreConfigCallable
|
||||
# Advertisement-only hub platforms; rp2 runs the full proxy and has its own
|
||||
# tests below.
|
||||
HUB_PLATFORM_FRAMEWORKS = [
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
]
|
||||
|
||||
HUB_TRACKERS = {
|
||||
PLATFORM_BK72XX: "bk72xx_ble_tracker",
|
||||
PLATFORM_LN882X: "ln882h_ble_tracker",
|
||||
PLATFORM_RP2: "rp2_ble_tracker",
|
||||
}
|
||||
|
||||
|
||||
def test_hub_platform_list_covers_every_hub_platform() -> None:
|
||||
# A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise
|
||||
# get no gate coverage at all; GATT platforms have their own tests.
|
||||
# A platform added to _HUB_PLATFORMS would otherwise get no gate coverage
|
||||
# at all; GATT platforms have their own tests.
|
||||
advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set(
|
||||
bluetooth_connection.HUB_MAX_CONNECTIONS
|
||||
)
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
"""Tests for the ethernet final-validation coexistence gate."""
|
||||
"""Tests for the ethernet final-validation coexistence gate and schema bounds."""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.ethernet import _final_validate
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
|
||||
from esphome.components.network import _validate_priority_list
|
||||
from esphome.const import CONF_PRIORITY
|
||||
from esphome.const import CONF_PRIORITY, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
_CH390_CONFIG = {
|
||||
"type": "CH390",
|
||||
"clk_pin": 47,
|
||||
"mosi_pin": 48,
|
||||
"miso_pin": 14,
|
||||
"cs_pin": 21,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_full_config():
|
||||
@@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
|
||||
)
|
||||
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
|
||||
def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum(
|
||||
set_core_config: SetCoreConfigCallable, clock_speed: str
|
||||
) -> None:
|
||||
"""CH390 SCK is rated to 72MHz, so the schema must accept the whole range."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
KEY_IDF_VERSION: cv.Version(5, 3, 2),
|
||||
},
|
||||
)
|
||||
# _validate derives use_address from the node name, which has no default here.
|
||||
CORE.name = "ch390-test"
|
||||
config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed})
|
||||
assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed)
|
||||
|
||||
|
||||
def test_ch390_rejects_clock_speed_above_the_datasheet_maximum(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The shared 80MHz ceiling is out of spec for this part."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
KEY_IDF_VERSION: cv.Version(5, 3, 2),
|
||||
},
|
||||
)
|
||||
# _validate derives use_address from the node name, which has no default here.
|
||||
CORE.name = "ch390-test"
|
||||
with pytest.raises(Invalid, match="value must be at most 72000000"):
|
||||
CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
mitsubishi_cn105:
|
||||
id: ac_hub
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
mitsubishi_cn105_id: ac_hub
|
||||
name: AC
|
||||
current_temperature_min_interval: 30s
|
||||
uart_id: uart_bus
|
||||
update_interval: 10s
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tests for Mitsubishi CN105 climate configuration migration diagnostics."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.mitsubishi_cn105 import climate
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.yaml_util import load_yaml
|
||||
|
||||
|
||||
def test_top_level_hub_rejects_leftover_legacy_climate_keys(
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
config = load_yaml(
|
||||
component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml")
|
||||
)
|
||||
CORE.raw_config = config
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
climate.CONFIG_SCHEMA(config["climate"][0])
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "'current_temperature_min_interval'" in message
|
||||
assert "'uart_id'" in message
|
||||
assert "'update_interval'" in message
|
||||
assert "top-level 'mitsubishi_cn105:' block" in message
|
||||
assert "'telemetry_request_min_interval'" in message
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
@@ -0,0 +1,4 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
host:
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from
|
||||
codegen (key-lookup backends) or must not (slot-based backends, whose managers
|
||||
have no load_from_key()). Run each platform's real codegen and assert the
|
||||
emission, mirroring the split the deny-list in esphome/core/defines.h assumes
|
||||
for static analysis.
|
||||
|
||||
The fixtures cover every distinct preferences backend today: ln882x and
|
||||
rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is
|
||||
an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new
|
||||
fixture here."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "emits"),
|
||||
[
|
||||
("esp32.yaml", True),
|
||||
("bk72xx.yaml", True), # libretiny
|
||||
("host.yaml", True),
|
||||
("nrf52.yaml", True), # zephyr
|
||||
("esp8266.yaml", False),
|
||||
("rp2.yaml", False),
|
||||
],
|
||||
)
|
||||
def test_key_lookup_define_matches_the_platform_backend(
|
||||
fixture: str,
|
||||
emits: bool,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path(fixture))
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits
|
||||
@@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning(
|
||||
generate_main: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Auth type basic builds Basic and does not warn."""
|
||||
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
|
||||
"""Auth type basic on ESP32 uses plaintext credentials and does not warn."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_basic.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_username("admin");' in main_cpp
|
||||
assert '->set_auth_password("password");' in main_cpp
|
||||
assert "set_auth_basic_hash" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
assert _DEFAULT_CHANGE_WARNING not in caplog.text
|
||||
|
||||
|
||||
def test_web_server_auth_basic_esp8266_uses_precomputed_hash(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp
|
||||
assert "set_auth_username" not in main_cpp
|
||||
assert "set_auth_password" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
|
||||
|
||||
def test_web_server_auth_digest_esp8266_uses_plaintext_credentials(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Auth type digest on ESP8266 uses plaintext credentials, not the basic hash."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_username("admin");' in main_cpp
|
||||
assert '->set_auth_password("password");' in main_cpp
|
||||
assert "set_auth_basic_hash" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
|
||||
|
||||
def test_web_server_auth_explicit_digest(
|
||||
generate_main: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: password
|
||||
type: basic
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: password
|
||||
type: digest
|
||||
@@ -0,0 +1,8 @@
|
||||
# Passive scanning variant: the package merge keeps the shared parameters from
|
||||
# common.yaml and overrides only the mode.
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
@@ -179,5 +179,15 @@ TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) {
|
||||
EXPECT_TRUE(unbound.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) {
|
||||
ScanResponseMerger partial;
|
||||
partial.bind(&this->dispatcher_, nullptr, "test");
|
||||
std::vector<uint8_t> data(20, 0xAA);
|
||||
partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
|
||||
partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
|
||||
partial.flush(); // dropped, not dispatched through half a binding
|
||||
EXPECT_TRUE(this->raw_.frames.empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the
|
||||
# tracker's packed-command start). Config-only: the CI base board generic-bk7252
|
||||
# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement
|
||||
# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot
|
||||
# collide with bk72xx_ble_tracker's own fixture id.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,91 @@
|
||||
// Pins the preferences contract concepts so the surface they enforce cannot
|
||||
// drift unnoticed: a minimal conforming type must satisfy each concept, and a
|
||||
// type missing a method or returning the wrong type must not.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/preference_backend.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
struct MinimalBackend {
|
||||
bool save(const uint8_t *, size_t) { return true; }
|
||||
bool load(uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(PreferenceBackendContract<MinimalBackend>);
|
||||
|
||||
struct BackendMissingLoad {
|
||||
bool save(const uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(!PreferenceBackendContract<BackendMissingLoad>);
|
||||
|
||||
struct BackendWrongReturn {
|
||||
void save(const uint8_t *, size_t) {}
|
||||
bool load(uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(!PreferenceBackendContract<BackendWrongReturn>);
|
||||
|
||||
struct MinimalPreferences : public PreferencesMixin<MinimalPreferences> {
|
||||
using PreferencesMixin<MinimalPreferences>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(PreferencesContract<MinimalPreferences>);
|
||||
|
||||
struct PreferencesMissingTwoArgForm : public PreferencesMixin<PreferencesMissingTwoArgForm> {
|
||||
using PreferencesMixin<PreferencesMissingTwoArgForm>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesMissingTwoArgForm>);
|
||||
|
||||
struct PreferencesMissingReset : public PreferencesMixin<PreferencesMissingReset> {
|
||||
using PreferencesMixin<PreferencesMissingReset>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesMissingReset>);
|
||||
|
||||
struct PreferencesWrongSyncReturn : public PreferencesMixin<PreferencesWrongSyncReturn> {
|
||||
using PreferencesMixin<PreferencesWrongSyncReturn>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
void sync() {}
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesWrongSyncReturn>);
|
||||
|
||||
// Forgot `using PreferencesMixin<X>::make_preference;`, so the derived
|
||||
// overloads hide the template forms (see the PreferencesContract note in
|
||||
// preference_backend.h); the concept must reject the class.
|
||||
struct PreferencesForgotUsingDeclaration : public PreferencesMixin<PreferencesForgotUsingDeclaration> {
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesForgotUsingDeclaration>);
|
||||
|
||||
struct MinimalKeyLookup {
|
||||
bool load_from_key(uint32_t, uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(PreferencesKeyLookupContract<MinimalKeyLookup>);
|
||||
|
||||
struct KeyLookupMissingMethod {};
|
||||
static_assert(!PreferencesKeyLookupContract<KeyLookupMissingMethod>);
|
||||
|
||||
TEST(PreferenceContract, NullBackendRefusesBothOperations) {
|
||||
// ESPPreferenceObject forwards to whichever backend the platform binds; a
|
||||
// default-constructed object has no backend and must refuse both operations
|
||||
// instead of crashing.
|
||||
ESPPreferenceObject without_backend;
|
||||
uint32_t value = 42;
|
||||
EXPECT_FALSE(without_backend.save(&value));
|
||||
EXPECT_FALSE(without_backend.load(&value));
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,19 @@
|
||||
ethernet:
|
||||
type: CH390
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
manual_ip:
|
||||
static_ip: 192.168.178.56
|
||||
gateway: 192.168.178.1
|
||||
subnet: 255.255.255.0
|
||||
domain: .local
|
||||
mac_address: "02:AA:BB:CC:DD:01"
|
||||
on_connect:
|
||||
- logger.log: "Ethernet connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "Ethernet disconnected!"
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-ch390.yaml
|
||||
@@ -75,7 +75,7 @@ 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 room temperature (0x03)
|
||||
// Now fetch telemetry (0x03)
|
||||
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));
|
||||
@@ -84,11 +84,11 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Room temperature response
|
||||
// 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});
|
||||
|
||||
// Room temperature should still have initial value
|
||||
// Room temperature from telemetry should still have initial value
|
||||
EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan());
|
||||
|
||||
ctx.sut.set_current_time(400);
|
||||
@@ -97,7 +97,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_TRUE(ctx.uart.rx.empty());
|
||||
EXPECT_TRUE(ctx.sut.is_status_initialized());
|
||||
|
||||
// Check room temperature we just read from received package
|
||||
// Check room temperature we just read from telemetry package
|
||||
EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f);
|
||||
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <vector>
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h"
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h"
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
@@ -65,11 +66,16 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
|
||||
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 static_cast<TestableMitsubishiCN105 &>(this->hp_).status_; }
|
||||
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
|
||||
|
||||
protected:
|
||||
MitsubishiCN105Component component_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
mitsubishi_cn105:
|
||||
id: ac
|
||||
uart_id: uart_bus
|
||||
update_interval: 30s
|
||||
telemetry_request_min_interval: 120s
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: ac
|
||||
mitsubishi_cn105_id: ac
|
||||
name: "AC Test"
|
||||
uart_id: uart_bus
|
||||
update_interval: 30s
|
||||
current_temperature_min_interval: 120s
|
||||
supported_swing_modes: BOTH
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- climate.mitsubishi_cn105.set_remote_temperature:
|
||||
- mitsubishi_cn105.set_remote_temperature:
|
||||
id: ac
|
||||
temperature: 22.0
|
||||
- climate.mitsubishi_cn105.clear_remote_temperature:
|
||||
- mitsubishi_cn105.clear_remote_temperature:
|
||||
id: ac
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: ac
|
||||
name: "AC Test"
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- climate.mitsubishi_cn105.set_remote_temperature:
|
||||
id: ac
|
||||
temperature: 22.0
|
||||
- climate.mitsubishi_cn105.clear_remote_temperature:
|
||||
id: ac
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
name: "AC Test"
|
||||
current_temperature_min_interval: 30s
|
||||
@@ -0,0 +1,6 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
name: "AC Test"
|
||||
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
name: "AC Test"
|
||||
uart_id: uart_bus
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
name: "AC Test"
|
||||
update_interval: 30s
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
|
||||
|
||||
mitsubishi_cn105:
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
name: "AC Test"
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
@@ -19,4 +20,14 @@ class NullUART : public uart::UARTComponent {
|
||||
void check_logger_conflict() override {}
|
||||
};
|
||||
|
||||
// A UART that records every byte written so tests can assert on the exact wire response.
|
||||
class RecordingUART : public NullUART {
|
||||
public:
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->written.insert(this->written.end(), data, data + len);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> written;
|
||||
};
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
|
||||
@@ -28,6 +28,27 @@ class RecordingDevice : public ModbusServerDevice {
|
||||
std::vector<uint16_t> last_values;
|
||||
};
|
||||
|
||||
// A server device that records the coil writes the hub routes to it. Coils arrive as a PackedBits view
|
||||
// over the hub's buffers, so the bits are copied out here rather than the view retained.
|
||||
class RecordingCoilDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit RecordingCoilDevice(uint8_t address) { this->set_address(address); }
|
||||
|
||||
ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override {
|
||||
this->write_count++;
|
||||
this->last_start_address = start_address;
|
||||
this->last_bits.clear();
|
||||
for (uint16_t i = 0; i != bits.size(); i++) {
|
||||
this->last_bits.push_back(bits[i]);
|
||||
}
|
||||
return std::nullopt; // return value is ignored for broadcasts, which are never answered
|
||||
}
|
||||
|
||||
int write_count{0};
|
||||
uint16_t last_start_address{0};
|
||||
std::vector<bool> last_bits;
|
||||
};
|
||||
|
||||
// A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch.
|
||||
class RejectingDevice : public ModbusServerDevice {
|
||||
public:
|
||||
@@ -41,15 +62,6 @@ class RejectingDevice : public ModbusServerDevice {
|
||||
int write_count{0};
|
||||
};
|
||||
|
||||
// A UART that records every byte written so the test can assert the hub sends no reply.
|
||||
class RecordingUART : public testing::NullUART {
|
||||
public:
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->written.insert(this->written.end(), data, data + len);
|
||||
}
|
||||
std::vector<uint8_t> written;
|
||||
};
|
||||
|
||||
// Drives full frames through the server hub's receive path in tests.
|
||||
class TestServerHub : public ModbusServerHub {
|
||||
public:
|
||||
@@ -75,6 +87,8 @@ class TestServerHub : public ModbusServerHub {
|
||||
|
||||
} // namespace
|
||||
|
||||
using testing::RecordingUART;
|
||||
|
||||
// A broadcast (address 0) single-register write reaches every registered device and is not answered.
|
||||
// Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length,
|
||||
// CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it.
|
||||
@@ -273,4 +287,83 @@ TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) {
|
||||
EXPECT_EQ(uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_ADDRESS));
|
||||
}
|
||||
|
||||
// A broadcast single-coil write (FC 0x05) reaches every device and is not answered. The 2-byte ON value
|
||||
// is normalized to a one-bit view, so the handler sees the same shape as a multiple-coil write of one.
|
||||
TEST(ModbusBroadcast, SingleCoilWriteReachesAllDevicesWithoutReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingCoilDevice device_a(0x02);
|
||||
RecordingCoilDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// FC 0x05 payload: coil 0x00AC, value 0xFF00 (ON).
|
||||
const uint8_t pdu_data[] = {0x00, 0xAC, 0xFF, 0x00};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
for (RecordingCoilDevice *device : {&device_a, &device_b}) {
|
||||
EXPECT_EQ(device->write_count, 1);
|
||||
EXPECT_EQ(device->last_start_address, 0x00AC);
|
||||
ASSERT_EQ(device->last_bits.size(), 1u);
|
||||
EXPECT_TRUE(device->last_bits[0]);
|
||||
}
|
||||
EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered
|
||||
}
|
||||
|
||||
// A broadcast multiple-coil write (FC 0x0F) delivers the packed bits to every device, LSB first.
|
||||
TEST(ModbusBroadcast, MultipleCoilWriteReachesAllDevicesWithoutReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingCoilDevice device_a(0x02);
|
||||
RecordingCoilDevice device_b(0x03);
|
||||
hub.register_device(&device_a);
|
||||
hub.register_device(&device_b);
|
||||
|
||||
// FC 0x0F payload: start 0x0013, 10 coils, 2 bytes, 0xCD 0x01 -> bit 0 set, bit 8 set.
|
||||
const uint8_t pdu_data[] = {0x00, 0x13, 0x00, 0x0A, 0x02, 0xCD, 0x01};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS), pdu_data, sizeof(pdu_data)));
|
||||
|
||||
for (RecordingCoilDevice *device : {&device_a, &device_b}) {
|
||||
EXPECT_EQ(device->write_count, 1);
|
||||
EXPECT_EQ(device->last_start_address, 0x0013);
|
||||
ASSERT_EQ(device->last_bits.size(), 10u);
|
||||
EXPECT_TRUE(device->last_bits[0]); // 0xCD bit 0
|
||||
EXPECT_FALSE(device->last_bits[1]); // 0xCD bit 1
|
||||
EXPECT_TRUE(device->last_bits[8]); // 0x01 bit 0
|
||||
EXPECT_FALSE(device->last_bits[9]); // padding bit
|
||||
}
|
||||
EXPECT_TRUE(uart.written.empty());
|
||||
}
|
||||
|
||||
// A coil broadcast that fails validation is dropped exactly like a bad register broadcast: no handler
|
||||
// call and, because broadcasts are never answered, no exception frame either.
|
||||
TEST(ModbusBroadcast, InvalidCoilBroadcastProducesNoWriteAndNoReply) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
|
||||
RecordingCoilDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
// Byte count disagrees with the coil quantity: 10 coils need 2 bytes, not 1.
|
||||
const uint8_t bad_count[] = {0x00, 0x13, 0x00, 0x0A, 0x01, 0xCD};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(
|
||||
BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS), bad_count, sizeof(bad_count)));
|
||||
EXPECT_EQ(device.write_count, 0);
|
||||
|
||||
// A single-coil value must be 0x0000 or 0xFF00; anything else is out of spec.
|
||||
const uint8_t bad_value[] = {0x00, 0xAC, 0x12, 0x34};
|
||||
ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
bad_value, sizeof(bad_value)));
|
||||
EXPECT_EQ(device.write_count, 0);
|
||||
|
||||
EXPECT_TRUE(uart.written.empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus
|
||||
|
||||
@@ -118,6 +118,35 @@ TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) {
|
||||
EXPECT_FALSE(call.status.has_value());
|
||||
}
|
||||
|
||||
// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read
|
||||
// start/count. The write half has no client-side ack callback - it is confirmed by a successful response.
|
||||
TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) {
|
||||
RecordingDevice device;
|
||||
// read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020
|
||||
const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF};
|
||||
const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 0x002A, 0x0100
|
||||
device.on_response(request, response);
|
||||
|
||||
ASSERT_EQ(device.holding_calls.size(), 1u);
|
||||
const auto &call = device.holding_calls.front();
|
||||
EXPECT_EQ(call.start_address, 0x0010); // the READ start address, not the write
|
||||
EXPECT_EQ(call.registers, (std::vector<uint16_t>{0x002A, 0x0100}));
|
||||
EXPECT_FALSE(call.status.has_value());
|
||||
EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side
|
||||
}
|
||||
|
||||
// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted
|
||||
// to on_custom_response(), never clamped and delivered as if complete.
|
||||
TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) {
|
||||
RecordingDevice device;
|
||||
const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF};
|
||||
const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested
|
||||
device.on_response(request, response);
|
||||
|
||||
EXPECT_TRUE(device.holding_calls.empty());
|
||||
EXPECT_EQ(device.custom_requests.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) {
|
||||
GenericDevice device;
|
||||
const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01};
|
||||
|
||||
@@ -426,6 +426,20 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
|
||||
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
|
||||
}
|
||||
|
||||
// --- packed bit helpers ------------------------------------------------------
|
||||
|
||||
TEST(ModbusHelpersTest, PackBitsAppendsToContainer) {
|
||||
// Bits are packed LSB first: the first value is bit 0 of the first byte, and the push_back
|
||||
// overload appends packed bytes onto a growable container preserving existing content.
|
||||
std::vector<bool> bits{true, false, true, true, false, false, false, false, true, true};
|
||||
std::vector<uint8_t> out{0x55}; // pre-existing content must be preserved
|
||||
pack_bits(out, bits);
|
||||
ASSERT_EQ(out.size(), 3u); // leading byte + 2 packed bytes (10 bits)
|
||||
EXPECT_EQ(out[0], 0x55);
|
||||
EXPECT_EQ(out[1], 0x0D); // 0b00001101
|
||||
EXPECT_EQ(out[2], 0x03); // bits 8 and 9 -> bits 0,1 of second byte
|
||||
}
|
||||
|
||||
// --- typed builders ----------------------------------------------------------
|
||||
|
||||
TEST(ModbusTypedBuilders, ReadPduWireBytes) {
|
||||
@@ -469,6 +483,71 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
|
||||
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) {
|
||||
const uint16_t write_values[] = {0x000B, 0x0016};
|
||||
// Read 2 registers at 0x0010, write 2 registers at 0x0020.
|
||||
auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values);
|
||||
const std::vector<uint8_t> expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20,
|
||||
0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
|
||||
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
|
||||
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) {
|
||||
const uint16_t one_value[] = {0x0001};
|
||||
const uint16_t two_values[] = {0x0001, 0x0002};
|
||||
// Read count out of range (zero and above the read ceiling).
|
||||
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty());
|
||||
EXPECT_TRUE(
|
||||
create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty());
|
||||
// Write count out of range (empty, and above the read/write ceiling which is lower than a plain write).
|
||||
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span<const uint16_t>()).empty());
|
||||
std::vector<uint16_t> too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA);
|
||||
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty());
|
||||
// Both blocks at their respective ceilings are accepted.
|
||||
std::vector<uint16_t> at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA);
|
||||
EXPECT_FALSE(
|
||||
create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty());
|
||||
// A block that runs past the 16-bit address space is refused (read block, then write block).
|
||||
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty());
|
||||
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty());
|
||||
// Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits.
|
||||
EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000
|
||||
EXPECT_FALSE(
|
||||
create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000
|
||||
}
|
||||
|
||||
TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) {
|
||||
const auto rw = static_cast<uint8_t>(FC::READ_WRITE_MULTIPLE_REGISTERS);
|
||||
// 0x17 both reads and writes, but it is not a pure (retry-safe) read.
|
||||
EXPECT_TRUE(is_function_code_read(rw));
|
||||
EXPECT_TRUE(is_function_code_write(rw));
|
||||
EXPECT_FALSE(is_function_code_read_only(rw));
|
||||
// Pure reads are read and read-only, never write.
|
||||
const auto rd = static_cast<uint8_t>(FC::READ_HOLDING_REGISTERS);
|
||||
EXPECT_TRUE(is_function_code_read(rd));
|
||||
EXPECT_TRUE(is_function_code_read_only(rd));
|
||||
EXPECT_FALSE(is_function_code_write(rd));
|
||||
// Plain writes are write only.
|
||||
const auto wr = static_cast<uint8_t>(FC::WRITE_MULTIPLE_REGISTERS);
|
||||
EXPECT_TRUE(is_function_code_write(wr));
|
||||
EXPECT_FALSE(is_function_code_read(wr));
|
||||
EXPECT_FALSE(is_function_code_read_only(wr));
|
||||
// Mask-write register mutates via read-modify-write, so it classes as a write, never a read.
|
||||
const auto mask = static_cast<uint8_t>(FC::MASK_WRITE_REGISTER);
|
||||
EXPECT_TRUE(is_function_code_write(mask));
|
||||
EXPECT_FALSE(is_function_code_read(mask));
|
||||
EXPECT_FALSE(is_function_code_read_only(mask));
|
||||
}
|
||||
|
||||
TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) {
|
||||
// The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead.
|
||||
const uint16_t values[] = {0x0001};
|
||||
EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast<const uint8_t *>(values),
|
||||
sizeof(values))
|
||||
.empty());
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) {
|
||||
// The container overload appends - the semantic every migrated caller relies on when a lambda
|
||||
// has already put words into the buffer.
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::modbus {
|
||||
|
||||
namespace {
|
||||
|
||||
// A server device backed by a small coil array: reads deliver the stored bits, writes apply them.
|
||||
class CoilDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit CoilDevice(uint8_t address) { this->set_address(address); }
|
||||
|
||||
ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override {
|
||||
this->read_count++;
|
||||
for (uint16_t i = 0; i < bits.size(); i++)
|
||||
bits.set(i, this->coils[start_address + i]);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override {
|
||||
this->write_count++;
|
||||
this->last_write_count = bits.size();
|
||||
for (uint16_t i = 0; i < bits.size(); i++)
|
||||
this->coils[start_address + i] = bits[i];
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool coils[32] = {};
|
||||
int read_count{0};
|
||||
int write_count{0};
|
||||
uint16_t last_write_count{0};
|
||||
};
|
||||
|
||||
// A device with no bit handlers, to exercise the ILLEGAL_FUNCTION defaults.
|
||||
class NoBitsDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit NoBitsDevice(uint8_t address) { this->set_address(address); }
|
||||
};
|
||||
|
||||
// Distinguishes the two bit-read entry points: each fills a different pattern and counts its calls, so a
|
||||
// test can prove FC 0x01 vs 0x02 dispatch routes to the right handler (and not merely that bits came back).
|
||||
class DualReadDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit DualReadDevice(uint8_t address) { this->set_address(address); }
|
||||
|
||||
ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override {
|
||||
this->coil_reads++;
|
||||
bits.set(0, true); // pattern 0x01
|
||||
return std::nullopt;
|
||||
}
|
||||
ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) override {
|
||||
this->discrete_reads++;
|
||||
bits.set(1, true); // pattern 0x02
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int coil_reads{0};
|
||||
int discrete_reads{0};
|
||||
};
|
||||
|
||||
// Overrides only on_read_bits() - the shared fallback the header documents that on_read_coils() and
|
||||
// on_read_discrete_inputs() default to. Both FC 0x01 and FC 0x02 must reach it.
|
||||
class BitsOnlyDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit BitsOnlyDevice(uint8_t address) { this->set_address(address); }
|
||||
ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) override {
|
||||
this->calls++;
|
||||
bits.set(0, true); // set bit 0 so the response proves the fallback ran
|
||||
return std::nullopt;
|
||||
}
|
||||
int calls{0};
|
||||
};
|
||||
|
||||
using testing::RecordingUART;
|
||||
|
||||
// Exposes the client-frame parser so a fully CRC-framed request can be pushed through the hub.
|
||||
class TestServerHub : public ModbusServerHub {
|
||||
public:
|
||||
bool tx_blocked() override { return false; }
|
||||
|
||||
void prime_send_timestamps_for_test() {
|
||||
uint32_t now = millis();
|
||||
this->last_modbus_byte_ = now;
|
||||
this->last_send_ = now;
|
||||
}
|
||||
|
||||
bool process_full_client_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data,
|
||||
size_t pdu_data_len) {
|
||||
this->rx_buffer_.clear();
|
||||
this->rx_buffer_.reserve(pdu_data_len + 4);
|
||||
this->rx_buffer_.push_back(address);
|
||||
this->rx_buffer_.push_back(function_code);
|
||||
this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len);
|
||||
uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size());
|
||||
this->rx_buffer_.push_back(crc & 0xFF);
|
||||
this->rx_buffer_.push_back(crc >> 8);
|
||||
return this->parse_modbus_client_frame_();
|
||||
}
|
||||
};
|
||||
|
||||
struct CoilFixture {
|
||||
CoilFixture() {
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.prime_send_timestamps_for_test();
|
||||
hub.register_device(&device);
|
||||
}
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
CoilDevice device{0x02};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// A coil read returns byte count + packed bits, set by the handler directly in the response buffer.
|
||||
TEST(ModbusServerCoils, ReadCoilsReturnsPackedBits) {
|
||||
CoilFixture f;
|
||||
f.device.coils[0] = true;
|
||||
f.device.coils[2] = true;
|
||||
f.device.coils[3] = true;
|
||||
f.device.coils[9] = true;
|
||||
|
||||
// FC 0x01: start 0x0000, quantity 10 -> 2 packed bytes
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(f.device.read_count, 1);
|
||||
// Response: address(1) + fc(1) + byte count(1) + packed(2) + CRC(2)
|
||||
ASSERT_EQ(f.uart.written.size(), 7u);
|
||||
EXPECT_EQ(f.uart.written[0], 0x02);
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::READ_COILS));
|
||||
EXPECT_EQ(f.uart.written[2], 2u); // byte count
|
||||
EXPECT_EQ(f.uart.written[3], 0x0D); // coils 0,2,3
|
||||
EXPECT_EQ(f.uart.written[4], 0x02); // coil 9 -> bit 1 of byte 1
|
||||
}
|
||||
|
||||
// A device overriding only on_read_bits() - the documented fallback - still serves both FC 0x01 (coils)
|
||||
// and FC 0x02 (discrete inputs), since on_read_coils()/on_read_discrete_inputs() default to it.
|
||||
TEST(ModbusServerCoils, ReadBitsFallbackServesBothCoilsAndDiscreteInputs) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.prime_send_timestamps_for_test();
|
||||
BitsOnlyDevice device{0x05};
|
||||
hub.register_device(&device);
|
||||
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x01}; // start 0x0000, quantity 1
|
||||
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
EXPECT_EQ(device.calls, 1);
|
||||
// address(1) + fc(1) + byte count(1) + packed(1) + CRC(2); bit 0 set -> 0x01
|
||||
ASSERT_EQ(uart.written.size(), 6u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::READ_COILS));
|
||||
EXPECT_EQ(uart.written[3], 0x01);
|
||||
|
||||
uart.written.clear();
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast<uint8_t>(FunctionCode::READ_DISCRETE_INPUTS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
EXPECT_EQ(device.calls, 2);
|
||||
ASSERT_EQ(uart.written.size(), 6u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::READ_DISCRETE_INPUTS));
|
||||
EXPECT_EQ(uart.written[3], 0x01);
|
||||
}
|
||||
|
||||
// A multiple-coil write hands the handler the packed wire bytes and echoes the request header.
|
||||
TEST(ModbusServerCoils, WriteMultipleCoilsAppliesPackedBits) {
|
||||
CoilFixture f;
|
||||
|
||||
// FC 0x0F: start 0x0000, quantity 10, byte count 2, packed values 0x0D 0x02
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x02, 0x0D, 0x02};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(f.device.write_count, 1);
|
||||
EXPECT_EQ(f.device.last_write_count, 10u);
|
||||
EXPECT_TRUE(f.device.coils[0]);
|
||||
EXPECT_FALSE(f.device.coils[1]);
|
||||
EXPECT_TRUE(f.device.coils[2]);
|
||||
EXPECT_TRUE(f.device.coils[3]);
|
||||
EXPECT_TRUE(f.device.coils[9]);
|
||||
EXPECT_FALSE(f.device.coils[10]);
|
||||
// Response echoes start address + quantity: address(1) + fc(1) + start(2) + quantity(2) + CRC(2)
|
||||
ASSERT_EQ(f.uart.written.size(), 8u);
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS));
|
||||
}
|
||||
|
||||
// A single-coil write (FC 0x05) is normalized to a one-bit packed buffer.
|
||||
TEST(ModbusServerCoils, WriteSingleCoilNormalizedToOneBit) {
|
||||
CoilFixture f;
|
||||
|
||||
const uint8_t pdu_on[] = {0x00, 0x03, 0xFF, 0x00}; // coil 3 ON
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
pdu_on, sizeof(pdu_on)));
|
||||
EXPECT_EQ(f.device.last_write_count, 1u);
|
||||
EXPECT_TRUE(f.device.coils[3]);
|
||||
|
||||
f.uart.written.clear();
|
||||
f.hub.prime_send_timestamps_for_test();
|
||||
const uint8_t pdu_off[] = {0x00, 0x03, 0x00, 0x00}; // coil 3 OFF
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
pdu_off, sizeof(pdu_off)));
|
||||
EXPECT_FALSE(f.device.coils[3]);
|
||||
EXPECT_EQ(f.device.write_count, 2);
|
||||
}
|
||||
|
||||
// An invalid single-coil value (not 0xFF00/0x0000) is rejected with ILLEGAL_DATA_VALUE, no write.
|
||||
TEST(ModbusServerCoils, InvalidSingleCoilValueRejected) {
|
||||
CoilFixture f;
|
||||
|
||||
const uint8_t pdu_data[] = {0x00, 0x03, 0x12, 0x34};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(f.device.write_count, 0);
|
||||
ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL) | 0x80);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_VALUE));
|
||||
}
|
||||
|
||||
// Read quantity validation lives in the shared read-request parser, so the register and bit reads cannot
|
||||
// drift apart. These pin both ends of the range for coils; the register case below pins that the same
|
||||
// parser is on that path too.
|
||||
TEST(ModbusServerCoils, ZeroCoilReadQuantityRejected) {
|
||||
CoilFixture f;
|
||||
|
||||
// FC 0x01: start 0x0000, quantity 0 - a read of nothing is out of spec.
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(f.device.read_count, 0);
|
||||
ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::READ_COILS) | 0x80);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_VALUE));
|
||||
}
|
||||
|
||||
TEST(ModbusServerCoils, OverLimitCoilReadQuantityRejected) {
|
||||
CoilFixture f;
|
||||
|
||||
// One past MAX_NUM_OF_COILS_TO_READ (2000 = 0x07D0), which no frame could carry anyway.
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x07, 0xD1};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(f.device.read_count, 0);
|
||||
ASSERT_EQ(f.uart.written.size(), 5u);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_VALUE));
|
||||
}
|
||||
|
||||
// The register read path shares that parser, so a zero quantity is rejected there identically. Lives
|
||||
// beside the coil cases deliberately: together they are what stops the shared parser being bypassed on
|
||||
// one side without the other noticing.
|
||||
TEST(ModbusServerCoils, ZeroRegisterReadQuantityRejectedByTheSameParser) {
|
||||
CoilFixture f;
|
||||
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_HOLDING_REGISTERS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
ASSERT_EQ(f.uart.written.size(), 5u);
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::READ_HOLDING_REGISTERS) | 0x80);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_VALUE));
|
||||
}
|
||||
|
||||
// A device without bit handlers rejects coil requests with ILLEGAL_FUNCTION via the defaults.
|
||||
TEST(ModbusServerCoils, UnhandledCoilReadIsIllegalFunction) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.prime_send_timestamps_for_test();
|
||||
NoBitsDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08};
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
ASSERT_EQ(uart.written.size(), 5u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::READ_COILS) | 0x80);
|
||||
EXPECT_EQ(uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_FUNCTION));
|
||||
}
|
||||
|
||||
// The view contracts are enforced, not merely documented: bytes() returns exactly ceil(size()/8) bytes
|
||||
// even over a larger buffer (forwarding it can never leak trailing buffer content), and set() drops
|
||||
// out-of-range bits instead of writing past the span (on the server read path that span wraps a stack
|
||||
// response buffer).
|
||||
TEST(ModbusServerCoils, PackedBitsViewContractsEnforced) {
|
||||
uint8_t buf[8] = {};
|
||||
PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer
|
||||
EXPECT_EQ(view.bytes().size(), 2u);
|
||||
|
||||
PackedBits short_view(std::span<const uint8_t>(buf, 1), 10); // contract-violating: 10 bits over 1 byte
|
||||
EXPECT_EQ(short_view.bytes().size(), 1u); // clamped to the real span, not a fabricated 2-byte span
|
||||
|
||||
MutablePackedBits bits(std::span<uint8_t>(buf, 2), 10);
|
||||
bits.set(9, true); // in range: lands in byte 1
|
||||
bits.set(10, true); // out of range: dropped
|
||||
bits.set(300, true); // far out of range: dropped, no write past the span
|
||||
EXPECT_EQ(buf[1], 0x02);
|
||||
for (size_t i = 2; i < sizeof(buf); i++)
|
||||
EXPECT_EQ(buf[i], 0) << "byte " << i;
|
||||
}
|
||||
|
||||
// FC 0x02 must dispatch to on_read_discrete_inputs, not on_read_coils: the two handlers fill different
|
||||
// patterns, so a swapped dispatch would fail on both the counters and the wire bytes.
|
||||
TEST(ModbusServerCoils, ReadDiscreteInputsDispatchesToItsOwnHandler) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.prime_send_timestamps_for_test();
|
||||
DualReadDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08};
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_DISCRETE_INPUTS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
EXPECT_EQ(device.discrete_reads, 1);
|
||||
EXPECT_EQ(device.coil_reads, 0);
|
||||
ASSERT_GE(uart.written.size(), 4u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::READ_DISCRETE_INPUTS));
|
||||
EXPECT_EQ(uart.written[3], 0x02); // the discrete handler's pattern, not the coil handler's
|
||||
|
||||
uart.written.clear();
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
EXPECT_EQ(device.coil_reads, 1);
|
||||
EXPECT_EQ(device.discrete_reads, 1);
|
||||
ASSERT_GE(uart.written.size(), 4u);
|
||||
EXPECT_EQ(uart.written[3], 0x01);
|
||||
}
|
||||
|
||||
// The write-side ILLEGAL_FUNCTION defaults: a device without bit handlers rejects coil writes too
|
||||
// (single and multiple), mirroring the read-side default already covered above.
|
||||
TEST(ModbusServerCoils, UnhandledCoilWriteIsIllegalFunction) {
|
||||
TestServerHub hub;
|
||||
RecordingUART uart;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.prime_send_timestamps_for_test();
|
||||
NoBitsDevice device(0x02);
|
||||
hub.register_device(&device);
|
||||
|
||||
const uint8_t single[] = {0x00, 0x03, 0xFF, 0x00};
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL),
|
||||
single, sizeof(single)));
|
||||
ASSERT_EQ(uart.written.size(), 5u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_SINGLE_COIL) | 0x80);
|
||||
EXPECT_EQ(uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_FUNCTION));
|
||||
|
||||
uart.written.clear();
|
||||
const uint8_t multiple[] = {0x00, 0x00, 0x00, 0x08, 0x01, 0xAA};
|
||||
ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS),
|
||||
multiple, sizeof(multiple)));
|
||||
ASSERT_EQ(uart.written.size(), 5u);
|
||||
EXPECT_EQ(uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80);
|
||||
EXPECT_EQ(uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_FUNCTION));
|
||||
}
|
||||
|
||||
// FC 0x0F with a byte count that does not match ceil(quantity / 8) is ILLEGAL_DATA_VALUE and never
|
||||
// reaches the handler.
|
||||
TEST(ModbusServerCoils, WriteCoilsByteCountMismatchRejected) {
|
||||
CoilFixture f;
|
||||
|
||||
// quantity 10 needs 2 bytes; claim 1
|
||||
const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x01, 0xFF};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS),
|
||||
pdu_data, sizeof(pdu_data)));
|
||||
|
||||
ASSERT_EQ(f.uart.written.size(), 5u);
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_VALUE));
|
||||
EXPECT_EQ(f.device.write_count, 0);
|
||||
}
|
||||
|
||||
// A coil range that runs past address 0xFFFF is ILLEGAL_DATA_ADDRESS and never reaches the handler.
|
||||
TEST(ModbusServerCoils, CoilAddressRangeOverflowRejected) {
|
||||
CoilFixture f;
|
||||
|
||||
// start 0xFFF8, quantity 16 -> 0x10008 > 0x10000
|
||||
const uint8_t pdu_data[] = {0xFF, 0xF8, 0x00, 0x10};
|
||||
ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast<uint8_t>(FunctionCode::READ_COILS), pdu_data,
|
||||
sizeof(pdu_data)));
|
||||
|
||||
ASSERT_EQ(f.uart.written.size(), 5u);
|
||||
EXPECT_EQ(f.uart.written[1], static_cast<uint8_t>(FunctionCode::READ_COILS) | 0x80);
|
||||
EXPECT_EQ(f.uart.written[2], static_cast<uint8_t>(ExceptionCode::ILLEGAL_DATA_ADDRESS));
|
||||
EXPECT_EQ(f.device.read_count, 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus
|
||||
@@ -35,6 +35,8 @@ button:
|
||||
id(bare_client).write_single_register(0x10, 42);
|
||||
id(bare_client).write_single_coil(0x01, true);
|
||||
id(bare_client_explicit_hub).read_holding_registers(0x20, 4);
|
||||
const uint16_t rw_vals[] = {1, 2};
|
||||
id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals);
|
||||
- platform: template
|
||||
name: "Send Read"
|
||||
on_press:
|
||||
@@ -134,3 +136,13 @@ button:
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0x01
|
||||
write_address: 0x0300
|
||||
values: !lambda "return {1, 2};"
|
||||
read_address: 0x0400
|
||||
read_count: 2
|
||||
on_response:
|
||||
then:
|
||||
# `values` here is the READ-BACK block, not the written block above
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/modbus_controller/modbus_controller.h"
|
||||
|
||||
namespace esphome::modbus_controller::testing {
|
||||
|
||||
// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum
|
||||
// because a fixed pack buffer sized for the maximum would silently truncate there while the quantity
|
||||
// field still claimed every coil - and the truncated frame would fit the RTU limit and go on the wire
|
||||
// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with
|
||||
// a log instead.
|
||||
TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
|
||||
ModbusController controller;
|
||||
std::vector<bool> coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true);
|
||||
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
|
||||
EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size()));
|
||||
}
|
||||
|
||||
// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce.
|
||||
TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
|
||||
ModbusController controller;
|
||||
const std::vector<bool> coils{true, false, true, true};
|
||||
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
|
||||
ASSERT_EQ(cmd.payload.size(), 1u);
|
||||
EXPECT_EQ(cmd.payload.data()[0], 0b00001101);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_controller::testing
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pins the OTA backend contract concept so the surface it enforces cannot
|
||||
// drift unnoticed: the build's real backend and a minimal conforming type
|
||||
// must satisfy it, and a type missing a method or returning the wrong type
|
||||
// must not.
|
||||
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#include "esphome/components/ota/ota_backend_host.h"
|
||||
|
||||
namespace esphome::ota::testing {
|
||||
|
||||
struct MinimalBackend {
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_OK; }
|
||||
void set_update_md5(const char *md5) {}
|
||||
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; }
|
||||
OTAResponseTypes end() { return OTA_RESPONSE_OK; }
|
||||
void abort() {}
|
||||
bool supports_compression() { return false; }
|
||||
};
|
||||
static_assert(OTABackendContract<MinimalBackend>);
|
||||
|
||||
// Each negative case derives from MinimalBackend and breaks exactly one
|
||||
// requirement; the declaration in the derived struct hides the conforming
|
||||
// one from the base.
|
||||
|
||||
// begin() without the default ota_type argument breaks consumers that only
|
||||
// pass the image size.
|
||||
struct BackendWithoutDefaultOTAType : MinimalBackend {
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type) { return OTA_RESPONSE_OK; }
|
||||
};
|
||||
static_assert(!OTABackendContract<BackendWithoutDefaultOTAType>);
|
||||
|
||||
struct BackendMissingAbort : MinimalBackend {
|
||||
void abort() = delete;
|
||||
};
|
||||
static_assert(!OTABackendContract<BackendMissingAbort>);
|
||||
|
||||
struct BackendWrongWriteReturn : MinimalBackend {
|
||||
bool write(uint8_t *data, size_t len) { return true; }
|
||||
};
|
||||
static_assert(!OTABackendContract<BackendWrongWriteReturn>);
|
||||
|
||||
// Pin the build's real backend, not just local mocks: the unit test harness
|
||||
// builds for the host platform, so this is the same check the factory's
|
||||
// static_assert performs in a firmware compile.
|
||||
#ifdef USE_HOST
|
||||
static_assert(OTABackendContract<HostOTABackend>);
|
||||
#endif
|
||||
|
||||
} // namespace esphome::ota::testing
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "esphome/components/ufm01/ufm01.h"
|
||||
|
||||
namespace esphome::ufm01::testing {
|
||||
|
||||
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
|
||||
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
|
||||
static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64;
|
||||
static constexpr uint8_t FRAME_STOP_BYTE = 0x16;
|
||||
static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B;
|
||||
static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C;
|
||||
static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D;
|
||||
static constexpr uint8_t COMMAND_ACK = 0xE5;
|
||||
|
||||
// UART mock with a byte queue for read-side simulation.
|
||||
class QueuedMockUART : public uart::UARTComponent {
|
||||
public:
|
||||
std::deque<uint8_t> rx_queue;
|
||||
std::vector<uint8_t> written_data;
|
||||
|
||||
void enqueue(const std::vector<uint8_t> &data) {
|
||||
this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end());
|
||||
}
|
||||
|
||||
void enqueue(std::initializer_list<uint8_t> data) {
|
||||
for (uint8_t byte : data)
|
||||
this->rx_queue.push_back(byte);
|
||||
}
|
||||
|
||||
void clear_rx() { this->rx_queue.clear(); }
|
||||
|
||||
bool read_array(uint8_t *data, size_t len) override {
|
||||
if (this->rx_queue.size() < len)
|
||||
return false;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
data[i] = this->rx_queue.front();
|
||||
this->rx_queue.pop_front();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool peek_byte(uint8_t *data) override {
|
||||
if (this->rx_queue.empty())
|
||||
return false;
|
||||
*data = this->rx_queue.front();
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t available() override { return this->rx_queue.size(); }
|
||||
|
||||
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
|
||||
|
||||
void write_array(const uint8_t *data, size_t len) override { this->written_data.assign(data, data + len); }
|
||||
|
||||
void check_logger_conflict() override {}
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
void load_settings(bool dump_config) override {}
|
||||
#endif
|
||||
};
|
||||
|
||||
class TestableUFM01 : public UFM01Component {
|
||||
public:
|
||||
void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); }
|
||||
|
||||
bool process_active_stream() { return this->process_active_stream_(); }
|
||||
|
||||
PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); }
|
||||
|
||||
bool consume_ack() { return this->consume_ack_(); }
|
||||
|
||||
void start_passive_read() { this->start_passive_read_(); }
|
||||
|
||||
void loop_startup() { this->loop_startup_(); }
|
||||
|
||||
OperatingMode operating_mode() const { return this->operating_mode_; }
|
||||
|
||||
StartupPhase startup_phase() const { return this->startup_phase_; }
|
||||
|
||||
int32_t read_index() const { return this->read_index_; }
|
||||
|
||||
size_t passive_index() const { return this->passive_index_; }
|
||||
|
||||
uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; }
|
||||
|
||||
void prepare_passive_read() {
|
||||
this->passive_index_ = 0;
|
||||
this->passive_start_ms_ = millis();
|
||||
}
|
||||
|
||||
void init_wait_phase() {
|
||||
this->operating_mode_ = OperatingMode::STARTUP;
|
||||
this->startup_phase_ = StartupPhase::WAIT;
|
||||
this->startup_wait_ms_ = 60000;
|
||||
this->phase_start_ms_ = millis();
|
||||
}
|
||||
|
||||
void reset_state() {
|
||||
this->read_index_ = 0;
|
||||
this->last_valid_frame_ms_ = 0;
|
||||
this->passive_index_ = 0;
|
||||
this->passive_read_pending_ = false;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::array<uint8_t, FRAME_SIZE> make_active_frame() {
|
||||
std::array<uint8_t, FRAME_SIZE> frame{};
|
||||
frame[0] = FRAME_START_BYTE_1;
|
||||
frame[1] = FRAME_START_BYTE_2;
|
||||
frame[15] = FRAME_FLAG_INSTANT_FLOW;
|
||||
frame[21] = FRAME_FLAG_RESERVED_SECTION;
|
||||
frame[24] = FRAME_FLAG_TEMP;
|
||||
frame[31] = FRAME_STOP_BYTE;
|
||||
uint8_t sum = 0;
|
||||
for (size_t i = 0; i < 30; ++i)
|
||||
sum += frame[i];
|
||||
frame[30] = sum;
|
||||
return frame;
|
||||
}
|
||||
|
||||
inline std::array<uint8_t, PASSIVE_FRAME_SIZE> make_passive_frame() {
|
||||
std::array<uint8_t, PASSIVE_FRAME_SIZE> frame{};
|
||||
frame[0] = FRAME_START_BYTE_1;
|
||||
frame[1] = PASSIVE_START_BYTE_2;
|
||||
frame[9] = FRAME_FLAG_INSTANT_FLOW;
|
||||
frame[15] = FRAME_FLAG_TEMP;
|
||||
frame[22] = FRAME_STOP_BYTE;
|
||||
uint8_t sum = 0;
|
||||
for (size_t i = 0; i < 21; ++i)
|
||||
sum += frame[i];
|
||||
frame[21] = sum;
|
||||
return frame;
|
||||
}
|
||||
|
||||
class UFM01Test : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->mock_uart_.clear_rx();
|
||||
this->mock_uart_.written_data.clear();
|
||||
this->ufm01_.set_mock_uart(&this->mock_uart_);
|
||||
this->ufm01_.reset_state();
|
||||
}
|
||||
|
||||
QueuedMockUART mock_uart_;
|
||||
TestableUFM01 ufm01_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ufm01::testing
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "common.h"
|
||||
|
||||
namespace esphome::ufm01::testing {
|
||||
|
||||
TEST_F(UFM01Test, ValidActiveFrameAccepted) {
|
||||
auto frame = make_active_frame();
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
|
||||
EXPECT_TRUE(this->ufm01_.process_active_stream());
|
||||
EXPECT_EQ(this->ufm01_.read_index(), 0);
|
||||
EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) {
|
||||
this->mock_uart_.enqueue({0x00, 0xFF, 0xAA});
|
||||
auto frame = make_active_frame();
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
|
||||
EXPECT_TRUE(this->ufm01_.process_active_stream());
|
||||
EXPECT_EQ(this->ufm01_.read_index(), 0);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) {
|
||||
auto frame = make_active_frame();
|
||||
frame[30] ^= 0xFF;
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
|
||||
EXPECT_FALSE(this->ufm01_.process_active_stream());
|
||||
EXPECT_EQ(this->ufm01_.read_index(), 0);
|
||||
EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) {
|
||||
auto frame = make_passive_frame();
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
this->ufm01_.prepare_passive_read();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS);
|
||||
EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE);
|
||||
EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, InvalidPassiveChecksumFails) {
|
||||
auto frame = make_passive_frame();
|
||||
frame[21] ^= 0xFF;
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
this->ufm01_.prepare_passive_read();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE);
|
||||
EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) {
|
||||
auto frame = make_passive_frame();
|
||||
this->mock_uart_.enqueue({0x00, 0x01, 0x02});
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
this->ufm01_.prepare_passive_read();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) {
|
||||
auto frame = make_passive_frame();
|
||||
this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99});
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
this->ufm01_.prepare_passive_read();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, PassiveReadPendingWhenPartial) {
|
||||
auto frame = make_passive_frame();
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.begin() + 10));
|
||||
this->ufm01_.prepare_passive_read();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING);
|
||||
EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE);
|
||||
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin() + 10, frame.end()));
|
||||
EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS);
|
||||
}
|
||||
|
||||
} // namespace esphome::ufm01::testing
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::ufm01::testing {
|
||||
|
||||
TEST(UFM01SetupPriority, IsLate) {
|
||||
TestableUFM01 ufm01;
|
||||
EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) {
|
||||
this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02});
|
||||
|
||||
EXPECT_TRUE(this->ufm01_.consume_ack());
|
||||
EXPECT_EQ(this->mock_uart_.available(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); }
|
||||
|
||||
TEST_F(UFM01Test, StartupWaitDetectsActiveStream) {
|
||||
auto frame = make_active_frame();
|
||||
this->mock_uart_.enqueue(std::vector<uint8_t>(frame.begin(), frame.end()));
|
||||
this->ufm01_.init_wait_phase();
|
||||
|
||||
this->ufm01_.loop_startup();
|
||||
|
||||
EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM);
|
||||
EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::WAIT);
|
||||
}
|
||||
|
||||
TEST_F(UFM01Test, StartPassiveReadSendsCommand) {
|
||||
this->ufm01_.start_passive_read();
|
||||
|
||||
ASSERT_EQ(this->mock_uart_.written_data.size(), 7u);
|
||||
EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE);
|
||||
EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE);
|
||||
EXPECT_EQ(this->mock_uart_.written_data[2], 0x11);
|
||||
EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B);
|
||||
EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE);
|
||||
}
|
||||
|
||||
} // namespace esphome::ufm01::testing
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
web_server: !include common_v2.yaml
|
||||
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
type: basic
|
||||
@@ -4,5 +4,5 @@ packages:
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: password
|
||||
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
type: basic
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-cli-rw
|
||||
|
||||
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
|
||||
|
||||
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
|
||||
# server's replies reach the client. auto_start so forwarding is active before the button fires.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_client
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_client
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_client
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: the read publishes what it returns, so the test can confirm the
|
||||
# write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(srv_read_1).publish_state(id(stored_1));
|
||||
return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
|
||||
sensor:
|
||||
# Server-side observations.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "srv_read_1"
|
||||
id: srv_read_1
|
||||
# Client-side read-back: the values the client's on_response received.
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0x01
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -756,3 +756,38 @@ async def test_uart_mock_modbus_fairness(
|
||||
f"controllers did not get a fair share of the bus: "
|
||||
f"controller 1 issued {count_1}, controller 2 issued {count_2}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_client_read_write(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end.
|
||||
|
||||
The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server
|
||||
applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran
|
||||
(srv_write_1) and the read half came back to the client's on_response (client_read_0 = the
|
||||
just-written 0x1234, client_read_1 = the read-only 0x00AA).
|
||||
"""
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
tracker = SensorTracker(
|
||||
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
|
||||
)
|
||||
futures = tracker.expect_all(
|
||||
{
|
||||
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
|
||||
"client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234
|
||||
"client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request
|
||||
}
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_all(futures)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
@@ -932,7 +932,7 @@ def test_string_no_slash__slash_replaced_with_warning(
|
||||
actual = cv.string_no_slash(value)
|
||||
assert actual == expected
|
||||
assert "reserved as a URL path separator" in caplog.text
|
||||
assert "will become an error in ESPHome 2026.7.0" in caplog.text
|
||||
assert "will become an error in ESPHome 2027.7.0" in caplog.text
|
||||
|
||||
|
||||
def test_string_no_slash__long_string_allowed() -> None:
|
||||
|
||||
Reference in New Issue
Block a user