Merge remote-tracking branch 'origin/dev' into store-yaml-firmware

# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
#	tests/integration/conftest.py
#	tests/unit_tests/test_yaml_util.py
This commit is contained in:
J. Nick Koston
2026-09-14 11:07:43 -05:00
2889 changed files with 129953 additions and 28736 deletions
+17
View File
@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
- `conftest.py` - Common fixtures and utilities
- `const.py` - Constants used throughout the integration tests
- `types.py` - Type definitions for fixtures and functions
- `raw_api_client.py` - Minimal plaintext api client whose reads happen only on request (for backpressure tests)
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
- `fixtures/` - YAML configuration files for tests
- `test_*.py` - Individual test files
@@ -20,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
- The fixture file must exist or the test will fail with a clear error message
- The fixture automatically injects a dynamic port number into the API configuration
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
of the test-named file and compile it in a shared, hash-keyed build directory, so
the whole group pays one full compile and each test only a relink. The marker
argument must be a single-line string literal (CI test selection maps fixtures to
test files by scanning for it), and marked tests must hand the `yaml_config`
content to `run_compiled` unmodified.
### Key Fixtures
- `run_compiled` - Combines write, compile, and run operations into a single context manager
@@ -187,6 +195,7 @@ loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""This callback only receives NEW state changes, not initial states."""
states[state.key] = state
@@ -195,6 +204,7 @@ def on_state(state: EntityState) -> None:
if not state_future.done():
state_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
@@ -228,6 +238,7 @@ loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check for specific condition using isinstance
@@ -235,6 +246,7 @@ def on_state(state: EntityState) -> None:
if not state_future.done():
state_future.set_result(state)
client.subscribe_states(on_state)
# Wait for state with timeout
@@ -263,11 +275,13 @@ entity_count = 50
received_states: set[int] = set()
all_states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
received_states.add(state.key)
if len(received_states) >= entity_count and not all_states_future.done():
all_states_future.set_result(True)
client.subscribe_states(on_state)
await asyncio.wait_for(all_states_future, timeout=10.0)
```
@@ -341,6 +355,7 @@ Create C++ components in `fixtures/external_components/` for:
- Custom entity behaviors
- Scheduler testing
- Memory management tests
- Deterministic network backpressure (`sndbuf_pin_component` pins socket send buffers; assert on its log line to prove the pin took effect)
##### Log Line Monitoring
```python
@@ -367,6 +382,7 @@ service_future = loop.create_future()
connected_pattern = re.compile(r"Client .* connected from")
service_pattern = re.compile(r"Service called")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
@@ -374,6 +390,7 @@ def check_output(line: str) -> None:
elif not service_future.done() and service_pattern.search(line):
service_future.set_result(True)
async with run_compiled(yaml_config, line_callback=check_output):
async with api_client_connected() as client:
# Wait for specific log message
+26
View File
@@ -0,0 +1,26 @@
"""Shared utilities for ESPHome integration tests - keeping output from failing tests."""
from __future__ import annotations
from pathlib import Path
#: Where a failing test leaves output for someone to look at afterwards. pytest's own
#: temporary folder is no use on a CI runner, which throws the whole workspace away when
#: the job ends; the workflow uploads this folder instead when a job fails.
ARTIFACT_DIR = Path(__file__).resolve().parents[2] / "test_artifacts"
def keep_artifact(name: str, data: bytes) -> Path:
"""Write ``data`` where it can still be read after the run, and return the path.
Args:
name: File name to write under the artifact folder.
data: Contents to write.
Returns:
The full path written.
"""
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
path = ARTIFACT_DIR / name
path.write_bytes(data)
return path
+161
View File
@@ -0,0 +1,161 @@
"""Shared utilities for ESPHome integration tests - reading BMP snapshots."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
import struct
# Size of the smallest BMP header pair (file header plus BITMAPINFOHEADER).
_MIN_HEADER_SIZE = 54
# How long capture_when_drawn() keeps asking for a picture with something on it.
DRAW_TIMEOUT = 15.0
@dataclass(frozen=True)
class Bmp:
"""A decoded BMP image."""
width: int
height: int
bits: int
#: Pixel data with the per row padding stripped, so it depends only on the image itself.
pixels: bytes
class NotABmpError(Exception):
"""The data is not a BMP at all, as opposed to a BMP that is still being written."""
def parse_bmp(data: bytes) -> Bmp | None:
"""Decode a BMP, or return None if the data is not a complete image yet.
Raises:
NotABmpError: If the data cannot become a valid BMP however much more is appended.
"""
# Writes go to the file in order, so a short read is always a prefix of what will be there.
# Anything wrong in a prefix we have already read is wrong for good, and worth saying now
# rather than reporting as a timeout later.
if len(data) >= 2 and data[:2] != b"BM":
raise NotABmpError(f"expected a BMP, got {data[:2]!r}")
if len(data) < _MIN_HEADER_SIZE:
return None
file_size = struct.unpack_from("<I", data, 2)[0]
offset = struct.unpack_from("<I", data, 10)[0]
width, height = struct.unpack_from("<ii", data, 18)
bits = struct.unpack_from("<H", data, 28)[0]
rows = abs(height)
row_size = ((width * bits + 31) // 32) * 4
if width <= 0 or rows == 0 or bits == 0 or offset < _MIN_HEADER_SIZE:
raise NotABmpError(
f"BMP header makes no sense: {width}x{height}, {bits} bits, "
f"pixels at offset {offset}"
)
if file_size < offset + row_size * rows:
raise NotABmpError(
f"BMP header claims {file_size} bytes, too few for {width}x{rows} "
f"at {bits} bits"
)
if len(data) < file_size:
return None
used = width * bits // 8
pixels = b"".join(
data[offset + row * row_size : offset + row * row_size + used]
for row in range(rows)
)
return Bmp(width=width, height=rows, bits=bits, pixels=pixels)
async def wait_for_bmp(path: Path, timeout: float = 5.0) -> Bmp:
"""Wait for a complete BMP file to appear at ``path`` and return it.
The file is created before any of its contents are written, so waiting for it to exist is
not enough - a read that wins the race sees a truncated image. Keep reading until the
headers say the whole image is there.
Args:
path: The file to wait for.
timeout: Maximum time to wait in seconds.
Returns:
The decoded image.
Raises:
AssertionError: If no complete image is readable within ``timeout``.
NotABmpError: If what was written is not a BMP. This is reported as soon as it is
seen, so a device that writes the wrong thing is named for what it did rather
than waiting out the timeout.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
try:
data = path.read_bytes()
except FileNotFoundError:
data = b""
if (image := parse_bmp(data)) is not None:
return image
if loop.time() >= deadline:
break
await asyncio.sleep(0.05)
if not data:
raise AssertionError(f"no snapshot appeared at {path} within {timeout}s")
raise AssertionError(
f"{path} was still incomplete after {timeout}s ({len(data)} bytes)"
)
def is_blank(image: Bmp) -> bool:
"""True if every pixel of the image is the same colour.
Whole pixels are counted rather than byte values: a plain background is usually made of more
than one distinct byte, so counting bytes would find several of them in a blank screen.
"""
return len({image.pixels[i : i + 3] for i in range(0, len(image.pixels), 3)}) <= 1
async def capture_when_drawn(
take: Callable[[str], Awaitable[None]],
directory: Path,
prefix: str = "drawn",
timeout: float = DRAW_TIMEOUT,
) -> tuple[Bmp, Path]:
"""Ask for snapshots until one has something drawn on it, and return it and where it went.
A display holds one flat colour until it first draws, which is one update interval after it
starts - long enough that a test connecting over the API can easily get in first. Capturing
once and hoping would compare a blank screen against whatever the test expects, reporting a
drawing fault where the real trouble was timing.
Args:
take: Asks the device for a snapshot under the name it is given.
directory: Where the device writes them.
prefix: Start of the names asked for. Each attempt needs its own, because a snapshot never
writes over a file that is already there.
timeout: How long to keep asking.
Returns:
The first image that is not one flat colour, and the path it was read from.
Raises:
AssertionError: If nothing had been drawn within ``timeout``.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
attempt = 0
while True:
attempt += 1
path = directory / f"{prefix}-{attempt}.bmp"
await take(path.name)
image = await wait_for_bmp(path)
if not is_blank(image):
return image, path
if loop.time() >= deadline:
raise AssertionError(
f"the screen was still a single flat colour after {timeout}s and "
f"{attempt} captures - nothing was drawn"
)
await asyncio.sleep(0.5)
+353 -72
View File
@@ -4,18 +4,22 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
import fcntl
from functools import cache
import hashlib
import logging
import os
from pathlib import Path
import platform
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from typing import TextIO
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
@@ -24,6 +28,13 @@ import pytest_asyncio
import esphome.config
from esphome.core import CORE
from esphome.helpers import (
get_usable_cpu_count,
read_file,
rmtree,
write_file,
write_file_if_changed,
)
from esphome.platformio.toolchain import get_idedata
from .const import (
@@ -56,22 +67,54 @@ import pty # not available on Windows
pytest.register_assert_rewrite("tests.integration.entity_utils")
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
"hash-keyed incremental build directory",
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
env = os.environ.copy()
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps")
# libdeps is keyed only by env name (the device name), and fixtures share
# names; two xdist workers first-compiling the same name race pio pkg
# install in the same directory. Keep libdeps per worker.
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
# Prevent cache cleaning during integration tests
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
# Cap each compile's -j so several xdist workers do not each spawn a
# full-width compiler fan-out on the same machine. An explicit env wins.
if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" not in os.environ:
workers = int(os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1"))
# Floor of 2 keeps a lone tail compile from running fully serial
env["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"] = str(
max(2, get_usable_cpu_count() // workers)
)
# Compile with THIS tree's esphome sources, not wherever the venv's editable
# install points (which may be a different git worktree or checkout).
repo_root = str(REPO_ROOT)
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
return env
@pytest.fixture(scope="session")
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts
test_cache_dir = Path.home() / ".esphome-integration-tests"
# Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT
cache_dir = test_cache_dir / "platformio"
# Use a lock file in the home directory to ensure only one process initializes the cache
@@ -94,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]:
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(fixture_path.read_text())
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
@@ -102,7 +147,7 @@ def shared_platformio_cache() -> Generator[Path]:
env = _get_platformio_env(cache_dir)
subprocess.run(
["esphome", "compile", str(config_path)],
[sys.executable, "-m", "esphome", "compile", str(config_path)],
check=True,
cwd=init_dir,
env=env,
@@ -163,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
return reserved_tcp_port[0]
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Give every test its own host prefs dir; prefs are keyed only by device
name, which tests sharing a fixture also share."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir
@pytest_asyncio.fixture
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
"""Load YAML configuration based on test name."""
# Get the test function name
test_name: str = request.node.name
# Extract the base test name (remove test_ prefix and any parametrization)
base_name = test_name.replace("test_", "").partition("[")[0]
shared_name = _shared_yaml_name(request)
# Base test name: test_ prefix and any parametrization stripped
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
# Load the fixture file
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
if not fixture_path.exists():
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
loop = asyncio.get_running_loop()
content = await loop.run_in_executor(None, fixture_path.read_text)
content = await loop.run_in_executor(None, read_file, fixture_path)
# Replace the port in the config if it contains an api section. Anchored to
# the start of a line so keys that merely end in "api:" are left alone.
@@ -200,11 +253,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
# Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
return content
@@ -214,24 +269,218 @@ async def write_yaml_config(
) -> AsyncGenerator[ConfigWriter]:
"""Write YAML configuration to a file."""
# Get the test name for default filename
test_name = request.node.name
base_name = test_name.replace("test_", "").split("[")[0]
base_name = request.node.name.replace("test_", "").partition("[")[0]
async def _write_config(content: str, filename: str | None = None) -> Path:
if filename is None:
filename = f"{base_name}.yaml"
config_path = integration_test_dir / filename
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, config_path.write_text, content)
await loop.run_in_executor(None, write_file, config_path, content)
return config_path
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
# Give a contended shared build lock time for a full cold compile ahead of us
_SHARED_LOCK_TIMEOUT_S = 900
_SHARED_LOCK_POLL_S = 0.1
_SHARED_LOCK_REPORT_S = 30
# Reclaims dirs orphaned by fixture renames or deleted checkouts
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
# ELF path per shared build dir; constant once compiled, so resolve it only once
_shared_elf_paths: dict[Path, Path] = {}
# Dirs this process already swept; pruning is session-scoped work
_pruned_dirs: set[Path] = set()
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
"""Name passed to the shared_yaml marker, or None when unmarked."""
marker = request.node.get_closest_marker("shared_yaml")
if marker is None:
return None
# Exactly one \w+ positional arg: the name doubles as a build dir
# component, and CI test selection (script/helpers.py) parses the same shape
if (
len(marker.args) != 1
or marker.kwargs
or not re.fullmatch(r"\w+", str(marker.args[0]))
):
raise ValueError(
"shared_yaml marker requires exactly one \\w+ fixture name literal"
)
return marker.args[0]
def _shared_build_prefix(name: str) -> str:
return f"{name}-{_REPO_KEY}-"
@cache
def _shared_build_dir(name: str) -> Path:
"""Dir keyed by checkout and fixture source, before per-test injections."""
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
"""ELF path recorded by the last completed compile, or None."""
try:
text = stamp.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
except OSError as err:
print(f"Cannot read {stamp}: {err}")
return None
if not text:
print(f"Ignoring empty stamp {stamp}")
return None
built = Path(text)
# Never trust a stamp pointing outside its own build dir as an unlink target
if shared_dir.resolve() in built.resolve().parents:
return built
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
return None
def _unused_since(stale: Path, cutoff: float) -> bool:
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
# Newest of the .built stamp (rewritten by every completed compile) and the
# dir itself (freshened by a worker claiming the dir before locking)
newest: float | None = None
for probe in (stale / ".built", stale):
try:
mtime = probe.stat().st_mtime
except FileNotFoundError:
continue
except NotADirectoryError:
return True # a stray file where a dir should be; reclaimable
except OSError as err:
print(f"Cannot age-probe {stale}: {err}")
return False # unknown never authorizes deletion
newest = mtime if newest is None else max(newest, mtime)
return newest is not None and newest < cutoff
def _prune_stale_builds(name: str, keep: Path) -> None:
"""Remove outdated build dirs (blocking, run in executor): this checkout's
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
other workers pruning the same dirs concurrently."""
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
prefix = _shared_build_prefix(name)
for stale in SHARED_BUILDS_ROOT.iterdir():
if stale == keep:
continue
same_fixture = stale.name.startswith(prefix)
if not same_fixture and not _unused_since(stale, cutoff):
continue
# Creating .lock bumps the dir mtime, so remember whether the re-probe
# under the lock can trust it
lock_preexisting = (stale / ".lock").exists()
try:
lock_file = (stale / ".lock").open("w")
except FileNotFoundError:
continue # pruned by another worker meanwhile
except NotADirectoryError:
print(f"Removing stray file {stale}")
stale.unlink(missing_ok=True)
continue
except OSError as err:
print(f"Cannot prune {stale}: {err}")
continue
with lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
continue # still in use by another run
# Re-probe under the lock: a worker freshens its dir before
# locking, so a just-claimed dir no longer looks unused. A dir
# whose .lock we just created cannot be held by anyone, and our
# own open bumped its mtime, so its pre-open probe stands
if (
lock_preexisting
and not same_fixture
and not _unused_since(stale, cutoff)
):
continue
# rmtree tolerates races; a leftover partial tree only costs a
# rebuild, since the ELF is deleted before every compile
try:
rmtree(stale)
except OSError as err:
print(f"Failed to prune {stale}: {err}")
async def _run_esphome_compile(
config_path: Path, cwd: Path, env: dict[str, str]
) -> None:
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=cwd,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
def _resolve_compiled_binary(config_path: Path) -> Path:
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
@@ -239,64 +488,96 @@ async def compile_esphome(
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
# Retry compilation up to 3 times if we get a segfault
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
"esphome",
"compile",
str(config_path),
cwd=integration_test_dir,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
# Success!
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
# Other error or final retry
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
# Load the config to get idedata (blocking call, must use executor)
loop = asyncio.get_running_loop()
def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
name = _shared_yaml_name(request)
if name is None:
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
# Get the compiled binary path
idedata = get_idedata(config)
return Path(idedata.firmware_elf_path)
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
# Shared fixture: build in a hash-keyed dir so tests sharing a config
# pay one full compile and later only a main.cpp (port) rebuild + relink
shared_dir = _shared_build_dir(name)
shared_dir.mkdir(parents=True, exist_ok=True)
# Freshen the dir before locking so a concurrent age sweep, which
# re-probes under the lock, never reaps a dir a worker just claimed;
# if a peer reaped it already, the guarded lock open recreates it
with suppress(FileNotFoundError):
os.utime(shared_dir)
if shared_dir not in _pruned_dirs:
_pruned_dirs.add(shared_dir)
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
shared_config = shared_dir / f"{name}.yaml"
private_binary = integration_test_dir / f"{name}.elf"
content = await loop.run_in_executor(None, read_file, config_path)
if content != getattr(request.node, "_shared_yaml_content", None):
# The dir is keyed by the fixture source; a mutated config would be
# cached under a hash that does not describe it
raise RuntimeError(
"shared_yaml tests must compile the yaml_config content unmodified"
)
# flock serializes concurrent xdist workers; closing the fd releases it.
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
# the wait cancellable, while a blocking acquire in an executor thread
# would survive test cancellation holding the fd
try:
lock_file = (shared_dir / ".lock").open("w")
except FileNotFoundError:
# A peer run pruning divergent hashes reaped the dir between our
# mkdir and this open; recreate it and pay a full rebuild
shared_dir.mkdir(parents=True, exist_ok=True)
lock_file = (shared_dir / ".lock").open("w")
with lock_file:
start = time.monotonic()
last_report = start
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
now = time.monotonic()
if now - start > _SHARED_LOCK_TIMEOUT_S:
raise RuntimeError(
f"Timed out waiting for the {shared_dir} lock"
) from None
if now - last_report >= _SHARED_LOCK_REPORT_S:
last_report = now
print(
f"Waited {now - start:.0f}s for another worker's "
f"build of {shared_dir.name}"
)
await asyncio.sleep(_SHARED_LOCK_POLL_S)
# .built carries the ELF path of the last completed compile, so
# later workers skip the config re-read in _resolve_compiled_binary
stamp = shared_dir / ".built"
if (built := _shared_elf_paths.get(shared_dir)) is None:
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
# Delete the ELF before compiling: whatever exists afterwards is
# this compile's output, so no staleness check is ever needed.
# With no usable stamp, sweep any leftover at the known layout
if built is not None:
built.unlink(missing_ok=True)
else:
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
for leftover in shared_dir.rglob("program"):
if leftover.is_file():
leftover.unlink()
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
None, _resolve_compiled_binary, shared_config
)
_shared_elf_paths[shared_dir] = built
await loop.run_in_executor(None, write_file, stamp, str(built))
# Copy out before unlocking: another worker may relink firmware.elf
# while this test is still running its private copy
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
return private_binary
yield _compile
+7
View File
@@ -9,6 +9,13 @@ API_CONNECTION_TIMEOUT = 30.0 # seconds
PORT_WAIT_TIMEOUT = 30.0 # seconds
PORT_POLL_INTERVAL = 0.1 # seconds
# The well-known all-zeros provisioning PSK, a key to provision over it, and
# the time the device takes to activate a newly saved key (100 ms timer plus
# margin)
ZERO_PSK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
PROVISIONING_PSK = b"bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="
KEY_ACTIVATION_DELAY = 0.5 # seconds
# Process shutdown timeouts
SIGINT_TIMEOUT = 5.0 # seconds
SIGTERM_TIMEOUT = 2.0 # seconds
@@ -0,0 +1,26 @@
esphome:
name: api-action-metadata-test
host:
api:
batch_delay: 0ms
actions:
- action: play_buzzer
description: Play an RTTTL melody on the buzzer
variables:
song_str:
type: string
description: RTTTL melody string
example: "two_short:d=4,o=5,b=100:16e6,16e6"
volume:
type: int
then:
- logger.log:
format: "Buzzer: %s"
args: [song_str.c_str()]
- action: plain_action
variables:
value: int
then:
- logger.log: "Plain action called"
logger:
@@ -0,0 +1,20 @@
esphome:
name: get-time-tz-test
host:
api:
logger:
time:
- platform: homeassistant
id: ha_time
sensor:
# Exposes the standard offset of the effective timezone so the test can
# observe which GetTimeResponse messages changed it
- platform: template
name: "TZ Offset"
id: tz_offset
accuracy_decimals: 0
update_interval: 100ms
lambda: |-
return time::get_global_tz().std_offset_seconds;
@@ -0,0 +1,59 @@
esphome:
name: ha-bs-initial
host:
api:
logger:
level: DEBUG
binary_sensor:
# trigger_on_initial_state: true must fire on_press for the first state from HA
- platform: homeassistant
name: Initial On
entity_id: binary_sensor.initial_on
trigger_on_initial_state: true
on_press:
- logger.log: "initial_on on_press"
on_release:
- logger.log: "initial_on on_release"
# Default (false) must not fire on the first state, only on later changes
- platform: homeassistant
name: Default
entity_id: binary_sensor.default
on_press:
- logger.log: "default on_press"
on_release:
- logger.log: "default on_release"
# Real HA startup shape: 'unavailable' arrives before the first real state
- platform: homeassistant
name: Unavailable First
entity_id: binary_sensor.unavailable_first
trigger_on_initial_state: true
on_press:
- logger.log: "unavailable_first on_press"
on_release:
- logger.log: "unavailable_first on_release"
# Initial 'off' must fire on_release when trigger_on_initial_state is set
- platform: homeassistant
name: Initial Off
entity_id: binary_sensor.initial_off
trigger_on_initial_state: true
on_press:
- logger.log: "initial_off on_press"
on_release:
- logger.log: "initial_off on_release"
# Same 'unavailable' first shape without the flag; must stay quiet on the
# first real state and only fire on the later change
- platform: homeassistant
name: Default Unavailable First
entity_id: binary_sensor.default_unavail
on_press:
- logger.log: "default_unavail on_press"
on_release:
- logger.log: "default_unavail on_release"
@@ -0,0 +1,23 @@
esphome:
name: api-backpressure-test
host:
api:
# Smallest queue so a non-draining client blocks the send path quickly
max_send_queue: 1
actions:
# GENERATED_ACTIONS
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [sndbuf_pin_component]
# Pins the device's socket send buffers for deterministic TCP backpressure
sndbuf_pin_component:
buffer_size: SERVER_SNDBUF
logger:
level: DEBUG
@@ -0,0 +1,19 @@
esphome:
name: camera-mock-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
mock_camera:
name: Mock Camera
# Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across
# multiple CameraImageResponse chunks and the client must reassemble.
# Must match IMAGE_SIZE in test_camera_mock.py.
image_size: 4096
@@ -0,0 +1,28 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core.entity_helpers import setup_entity
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
AUTO_LOAD = ["camera"]
CONF_IMAGE_SIZE = "image_size"
mock_camera_ns = cg.esphome_ns.namespace("mock_camera")
MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase)
CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(MockCamera),
cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_CAMERA")
var = cg.new_Pvariable(config[CONF_ID])
await setup_entity(var, config, "camera")
await cg.register_component(var, config)
cg.add(var.set_image_size(config[CONF_IMAGE_SIZE]))
@@ -0,0 +1,30 @@
#include "mock_camera.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::mock_camera {
static const char *const TAG = "mock_camera";
void MockCamera::loop() {
uint8_t requesters = this->single_requesters_ | this->stream_requesters_;
if (requesters == 0)
return;
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS)
return;
this->last_frame_ms_ = now;
this->single_requesters_ = 0;
auto image = std::make_shared<MockCameraImage>(this->image_size_, this->frame_counter_, requesters);
ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_,
requesters);
this->frame_counter_++;
for (auto *listener : this->listeners_) {
listener->on_camera_image(image);
}
}
void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); }
} // namespace esphome::mock_camera
@@ -0,0 +1,80 @@
#pragma once
#include "esphome/components/camera/camera.h"
#include "esphome/core/component.h"
#include <memory>
#include <vector>
namespace esphome::mock_camera {
/** Deterministic in-memory camera image.
* Byte i of frame N is (N + i) & 0xFF so tests can validate
* reassembled data from just the first byte.
*/
class MockCameraImage : public camera::CameraImage {
public:
MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters)
: data_(new uint8_t[size]), size_(size), requesters_(requesters) {
for (size_t i = 0; i < size; i++) {
this->data_[i] = static_cast<uint8_t>(frame_counter + i);
}
}
uint8_t *get_data_buffer() override { return this->data_.get(); }
size_t get_data_length() override { return this->size_; }
bool was_requested_by(camera::CameraRequester requester) const override {
return (this->requesters_ & (1 << requester)) != 0;
}
protected:
std::unique_ptr<uint8_t[]> data_;
size_t size_;
uint8_t requesters_;
};
class MockCameraImageReader : public camera::CameraImageReader {
public:
void set_image(std::shared_ptr<camera::CameraImage> image) override {
this->image_ = std::move(image);
this->offset_ = 0;
}
size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; }
uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; }
void consume_data(size_t consumed) override { this->offset_ += consumed; }
void return_image() override {
this->image_.reset();
this->offset_ = 0;
}
protected:
std::shared_ptr<camera::CameraImage> image_;
size_t offset_{0};
};
/** Virtual camera producing deterministic frames on request or stream. */
class MockCamera : public camera::Camera {
public:
void loop() override;
void dump_config() override;
void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); }
camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); }
void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); }
void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); }
void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); }
void set_image_size(uint32_t size) { this->image_size_ = size; }
protected:
static constexpr uint32_t FRAME_INTERVAL_MS = 50;
// Members ordered largest to smallest to minimize padding
std::vector<camera::CameraListener *> listeners_;
uint32_t image_size_{1024};
uint32_t last_frame_ms_{0};
uint8_t frame_counter_{0};
uint8_t single_requesters_{0};
uint8_t stream_requesters_{0};
};
} // namespace esphome::mock_camera
@@ -0,0 +1,20 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID
DEPENDENCIES = ["api"]
sndbuf_pin_ns = cg.esphome_ns.namespace("sndbuf_pin")
SndbufPinComponent = sndbuf_pin_ns.class_("SndbufPinComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(SndbufPinComponent),
cv.Required(CONF_BUFFER_SIZE): cv.int_range(min=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], config[CONF_BUFFER_SIZE])
await cg.register_component(var, config)
@@ -0,0 +1,55 @@
#include "sndbuf_pin_component.h"
#include <netinet/in.h>
#include <sys/socket.h>
#include <cerrno>
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
namespace esphome::sndbuf_pin {
static const char *const TAG = "sndbuf_pin";
// Skip stdio; scan the low fd range where the listeners land
static constexpr int FIRST_USER_FD = 3;
static constexpr int MAX_FD_SCAN = 128;
void SndbufPinComponent::setup() {
int pinned = 0;
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
int type = 0;
socklen_t len = sizeof(type);
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
continue;
struct sockaddr_in addr {};
socklen_t addr_len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
continue;
}
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
continue;
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
continue;
}
int applied = 0;
len = sizeof(applied);
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
// Linux doubles the requested value; anything below it means clamped
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
continue;
}
// Tests assert on this line; accepted sockets inherit the pinned size
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
applied);
pinned++;
}
if (pinned == 0) {
ESP_LOGE(TAG, "api listener socket was not pinned");
this->mark_failed();
}
}
} // namespace esphome::sndbuf_pin
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::sndbuf_pin {
// Test-only (host): pins SO_SNDBUF on every open TCP socket so integration
// tests get deterministic backpressure; an explicit SO_SNDBUF also disables
// kernel autotuning, and accepted sockets inherit it from the listener.
class SndbufPinComponent : public Component {
public:
explicit SndbufPinComponent(int buffer_size) : buffer_size_(buffer_size) {}
void setup() override;
// After the api server so its listening socket exists
float get_setup_priority() const override { return setup_priority::LATE; }
protected:
int buffer_size_;
};
} // namespace esphome::sndbuf_pin
@@ -0,0 +1,39 @@
"""Host-only stub of the wifi component for integration tests.
HOST-ONLY TEST COMPONENT: this shadows the real wifi component for EVERY
fixture that uses the shared external_components directory. Any host fixture
with a wifi block gets this stub, not the real component: fixed scan results,
is_connected() hardwired true, and save_wifi_sta that only logs. See
wifi_component.h for the full behavior.
"""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PASSWORD, CONF_SSID, CONF_USE_ADDRESS
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
wifi_ns = cg.esphome_ns.namespace("wifi")
WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(WiFiComponent),
# Accepted for fixture realism; the stub ignores them
cv.Optional(CONF_SSID): cv.string,
cv.Optional(CONF_PASSWORD): cv.string,
# Read by StorageJSON via CORE.address whenever a wifi block exists
cv.Optional(CONF_USE_ADDRESS, default="localhost"): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
def check_placeholder_credentials(config: ConfigType) -> None:
"""Compile-time hook the esphome CLI imports from the wifi module; no-op here."""
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_define("USE_WIFI")
@@ -0,0 +1 @@
../../../../../esphome/components/wifi/scan_list.h
@@ -0,0 +1,42 @@
#include "wifi_component.h"
#include "esphome/core/log.h"
namespace esphome::wifi {
static const char *const TAG = "wifi_stub";
WiFiComponent *global_wifi_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
void WiFiComponent::setup() { ESP_LOGI(TAG, "Stub wifi ready"); }
void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, "Stub wifi"); }
void WiFiComponent::start_scanning() {
// Duplicate TestNet entry (weaker) and a hidden entry exercise the
// should_show_scan_entry dedup and filtering logic
this->scan_result_.clear();
this->scan_result_.emplace_back("TestNet", -50, true, false);
this->scan_result_.emplace_back("TestNet", -60, true, false);
this->scan_result_.emplace_back("OpenNet", -70, false, false);
this->scan_result_.emplace_back("", -40, false, true);
ESP_LOGI(TAG, "Scan complete with %zu results", this->scan_result_.size());
}
void WiFiComponent::set_sta(const WiFiAP &ap) { ESP_LOGI(TAG, "set_sta ssid=%s", ap.get_ssid().c_str()); }
void WiFiComponent::start_connecting(const WiFiAP &ap) {
ESP_LOGI(TAG, "start_connecting ssid=%s", ap.get_ssid().c_str());
// Connecting succeeds immediately, so the requested network is the connected one
this->connected_ssid_ = ap.get_ssid().c_str();
}
void WiFiComponent::clear_sta() { ESP_LOGI(TAG, "clear_sta"); }
void WiFiComponent::save_wifi_sta(StringRef ssid, StringRef password) {
ESP_LOGI(TAG, "save_wifi_sta ssid=%s password_len=%zu", ssid.c_str(), password.size());
}
} // namespace esphome::wifi
@@ -0,0 +1,88 @@
#pragma once
// ============================================================================
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
//
// Stub of the real wifi component with just enough API surface for
// improv_serial to build and run on the host platform. Scan results are
// fixed, "connecting" succeeds immediately, and save_wifi_sta only logs so
// tests can assert on the log output.
// ============================================================================
#include "esphome/components/network/ip_address.h"
#include "esphome/core/component.h"
#include "esphome/core/string_ref.h"
#include <cstdio>
#include <span>
#include <string>
#include <vector>
namespace esphome::wifi {
static constexpr size_t SSID_BUFFER_SIZE = 33;
class WiFiAP {
public:
void set_ssid(const char *ssid) { this->ssid_ = ssid; }
void set_password(const char *password) { this->password_ = password; }
StringRef get_ssid() const { return StringRef(this->ssid_); }
StringRef get_password() const { return StringRef(this->password_); }
protected:
std::string ssid_;
std::string password_;
};
class WiFiScanResult {
public:
WiFiScanResult(const char *ssid, int8_t rssi, bool with_auth, bool hidden)
: ssid_(ssid), rssi_(rssi), with_auth_(with_auth), hidden_(hidden) {}
StringRef get_ssid() const { return StringRef(this->ssid_); }
int8_t get_rssi() const { return this->rssi_; }
bool get_with_auth() const { return this->with_auth_; }
bool get_is_hidden() const { return this->hidden_; }
bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; }
protected:
std::string ssid_;
int8_t rssi_;
bool with_auth_;
bool hidden_;
};
class WiFiComponent : public Component {
public:
WiFiComponent();
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::WIFI; }
bool has_sta() const { return false; }
bool is_disabled() const { return false; }
// Always connected so network::is_connected() keeps the API server accepting clients
bool is_connected() const { return true; }
// Reports the network start_connecting() was last asked for, so a consumer checking that it
// joined the network it requested (rather than an earlier one) sees the connect succeed
const char *wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
snprintf(buffer.data(), buffer.size(), "%s", this->connected_ssid_.c_str());
return buffer.data();
}
void start_scanning();
const std::vector<WiFiScanResult> &get_scan_result() const { return this->scan_result_; }
void set_sta(const WiFiAP &ap);
void start_connecting(const WiFiAP &ap);
void clear_sta();
void save_wifi_sta(StringRef ssid, StringRef password);
// Called by network::util on any USE_WIFI build
const char *get_use_address() const { return "localhost"; }
network::IPAddresses get_ip_addresses() { return {}; }
protected:
std::vector<WiFiScanResult> scan_result_;
std::string connected_ssid_;
};
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::wifi
@@ -0,0 +1,12 @@
esphome:
name: host-ota-test
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: __OTA_PORT__
password: "hunter2"
logger:
level: DEBUG
@@ -0,0 +1,11 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
logger:
level: DEBUG
@@ -0,0 +1,10 @@
esphome:
name: host-ota-test
host:
api:
encryption:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
@@ -0,0 +1,41 @@
esphome:
name: test_suspend_resume_device
host:
logger:
level: DEBUG
api:
preferences:
id: prefs_syncer
flash_write_interval: 1s
button:
- platform: template
name: "Save Preference"
on_press:
- lambda: |-
// save() only updates the in-memory map; only sync() persists it to disk.
ESPPreferenceObject pref = global_preferences->make_preference<uint32_t>(0xBEEF);
uint32_t value = 123;
if (pref.save(&value)) {
ESP_LOGI("test", "Preference saved in memory");
} else {
ESP_LOGE("test", "Preference save failed");
}
- platform: template
name: "Suspend Syncer"
on_press:
- component.suspend: prefs_syncer
- lambda: |-
ESP_LOGI("test", "Syncer suspended");
- platform: template
name: "Resume Syncer"
on_press:
- component.resume: prefs_syncer
- lambda: |-
ESP_LOGI("test", "Syncer resumed");
@@ -0,0 +1,42 @@
esphome:
# Short name keeps the device info payload under uart_mock's 64 byte log cap
name: improv-uart
host:
api:
actions:
- action: uart_inject
variables:
payload: int[]
then:
- uart_mock.inject_rx:
id: mock_uart
data: !lambda return std::vector<uint8_t>(payload.begin(), payload.end());
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Host-only stub shadowing the real wifi component (see external_components/wifi)
wifi:
ssid: TestNet
password: password1
# Dummy uart entry so the uart component sources are part of the build; the
# actual bus used by improv_serial is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
improv_serial:
uart_id: mock_uart
# Deterministic on host: only the device name placeholder is used
next_url: https://example.com/?device={{device_name}}
@@ -0,0 +1,29 @@
esphome:
name: light-binary-effect-off
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
effects:
- strobe:
name: "Fast Strobe"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
@@ -0,0 +1,21 @@
esphome:
name: light-binary-zero-bright
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
@@ -0,0 +1,53 @@
esphome:
name: lvgl-headless-render-test
host:
api:
actions:
# The name comes from the test so it can capture more than once: a snapshot never writes over
# a file that is already there, so a fixed name could only ever be captured once.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: lvgl_display
filename: !lambda return name;
logger:
level: DEBUG
display:
# A display with no screen, so what LVGL draws depends on LVGL alone - nothing about the machine
# running the test, and no graphics library outside this repository, can move the result.
- platform: snapshot
id: lvgl_display
auto_clear_enabled: false
dimensions:
width: 300
height: 300
# The widgets are spelled out here rather than left to the built in "Hello World" screen, which
# LVGL builds when nothing is configured: that screen contains a spinner, and an animation cannot
# produce the same picture twice.
#
# Everything that affects the rendered pixels is set explicitly, so the expected hash in the test
# depends only on the drawing code and the built in font. In particular the background comes from a
# full screen object rather than from the theme, so adjusting a theme default does not break this.
lvgl:
displays: lvgl_display
default_font: montserrat_14
widgets:
- obj:
width: 100%
height: 100%
bg_color: 0x000080
bg_opa: cover
border_width: 0
radius: 0
pad_all: 0
widgets:
- label:
align: center
text: "Hello World!"
text_color: 0xFFFFFF
@@ -0,0 +1,28 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
format: AUTO
id: myimg
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG
@@ -0,0 +1,28 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: AUTO
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG
@@ -7,8 +7,9 @@ http_request:
display:
online_image:
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: BMP
type: RGB
@@ -0,0 +1,35 @@
esphome:
name: host-pref-key-stability
host:
api:
logger:
switch:
- platform: template
id: test_switch_restore
name: Test Switch
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
number:
- platform: template
id: test_number_restore
name: Test Number
optimistic: true
restore_value: true
initial_value: 1.0
min_value: 0
max_value: 100
step: 0.5
text:
- platform: template
id: test_text_restore
name: Test Text
mode: text
optimistic: true
restore_value: true
initial_value: fallback
min_length: 0
max_length: 20
@@ -1,5 +1,17 @@
esphome:
name: test-script-queued
on_boot:
# Default priority (600.0) runs before the script component is set up
# This tests that an instance queued during boot still gets dequeued
# once the main loop starts (the idle-loop disabling must not eat it)
then:
- logger.log: "=== BOOT: Executing queued script twice ==="
- script.execute:
id: boot_script
tag: 1
- script.execute:
id: boot_script
tag: 2
host:
api:
@@ -98,6 +110,15 @@ api:
- script.execute: no_params_script
- script.execute: no_params_script
# Test 6: Re-execute after stop() cleared the queue
# (the idle loop must re-enable on demand)
- action: test_after_stop
then:
- logger.log: "=== TEST 6: Re-execute after stop ==="
- script.execute:
id: stop_script
num: 9
logger:
level: DEBUG
@@ -168,3 +189,18 @@ script:
- logger.log: "No params: START"
- delay: 50ms
- logger.log: "No params: END"
# Boot script: executed twice from on_boot before setup()
- id: boot_script
mode: queued
max_runs: 3
parameters:
tag: int
then:
- logger.log:
format: "Boot queued: START %d"
args: ['tag']
- delay: 50ms
- logger.log:
format: "Boot queued: END %d"
args: ['tag']
@@ -0,0 +1,25 @@
esphome:
name: test-script-queued-idle
host:
api:
actions:
# Execute twice: the first runs immediately, the second gets queued,
# which must re-enable the loop; draining must disable it again
- action: run_twice
then:
- script.execute: idle_script
- script.execute: idle_script
# VERY_VERBOSE exposes the component framework's "loop disabled" and
# "loop enabled" messages that this test asserts on
logger:
level: VERY_VERBOSE
script:
- id: idle_script
mode: queued
then:
- logger.log: "idle_script: START"
- delay: 50ms
- logger.log: "idle_script: END"
@@ -0,0 +1,29 @@
esphome:
name: sdl-headless-screenshot-test
host:
api:
actions:
# The name comes from the test so it can capture more than once while it waits for the first
# frame: a snapshot never writes over a file that is already there.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: sdl_display
filename: !lambda return name;
logger:
level: DEBUG
display:
- platform: sdl
id: sdl_display
headless: true
show_test_card: true
update_interval: 100ms
# An odd width exercises the row padding in the BMP writer
dimensions:
width: 101
height: 64
@@ -1,58 +0,0 @@
esphome:
name: test-batch-window-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensor that we'll use to publish values
sensor:
- platform: template
name: "Source Sensor"
id: source_sensor
accuracy_decimals: 2
# Batch window filters (window_size == send_every) - use streaming filters
- platform: copy
source_id: source_sensor
name: "Min Sensor"
id: min_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Max Sensor"
id: max_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Moving Avg Sensor"
id: moving_avg_sensor
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 5
send_first_at: 1
# Button to trigger publishing test values
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- lambda: |-
// Publish 10 values: 1.0, 2.0, ..., 10.0
for (int i = 1; i <= 10; i++) {
id(source_sensor).publish_state(float(i));
}
@@ -33,6 +33,11 @@ sensor:
id: source_sensor_5
accuracy_decimals: 1
- platform: template
name: "Source Sensor 6"
id: source_sensor_6
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -81,6 +86,13 @@ sensor:
filters:
- delta: 50%
- platform: copy
source_id: source_sensor_6
name: "Filter NaN"
id: filter_nan
filters:
- delta: 0
script:
- id: test_filter_min
then:
@@ -188,6 +200,24 @@ script:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
- id: test_filter_nan
then:
- sensor.template.publish:
id: source_sensor_6
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;"
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;" # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 2.0
button:
- platform: template
name: "Test Filter Min"
@@ -218,3 +248,9 @@ button:
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage
- platform: template
name: "Test Filter NaN"
id: btn_filter_nan
on_press:
- script.execute: test_filter_nan
@@ -0,0 +1,34 @@
esphome:
name: set-internal-at-boot
on_boot:
then:
- lambda: |-
id(hidden_at_boot).set_internal(true);
id(shown_at_boot).set_internal(false);
host:
api:
actions:
- action: set_internal_late
then:
- lambda: id(untouched).set_internal(true);
logger:
sensor:
- platform: template
name: "Hidden At Boot"
id: hidden_at_boot
lambda: return 1.0;
- platform: template
name: "Shown At Boot"
id: shown_at_boot
internal: true
lambda: return 2.0;
- platform: template
name: "Untouched"
id: untouched
lambda: return 3.0;
@@ -0,0 +1,28 @@
esphome:
name: snapshot-display-test
host:
api:
actions:
# The name comes from the test so it can ask for several in a row and check what each one
# does with it.
- action: take_snapshot
variables:
name: string
then:
- snapshot.take:
id: snapshot_display
filename: !lambda return name;
logger:
level: DEBUG
display:
- platform: snapshot
id: snapshot_display
show_test_card: true
update_interval: 100ms
# An odd width exercises the row padding in the BMP writer
dimensions:
width: 101
height: 64
@@ -0,0 +1,72 @@
esphome:
name: tmpl-clim-basic
on_boot:
- climate.template.publish:
id: test_climate
action: IDLE
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Basic Climate
optimistic: true
sensor: test_climate_current_temperature
humidity_sensor: test_climate_current_humidity
supports_action: true
supported_modes:
- "OFF"
- HEAT
- COOL
supported_fan_modes:
- AUTO
- LOW
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature().has_value())
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
if (x.get_fan_mode().has_value())
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
if (x.get_swing_mode().has_value())
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
if (x.get_preset().has_value())
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 22.5f;"
update_interval: 10ms
- platform: template
id: test_climate_current_humidity
name: Test Climate Current Humidity
lambda: "return 55.0f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
mode: "OFF"
fan_mode: AUTO
swing_mode: "OFF"
preset: NONE
@@ -0,0 +1,47 @@
esphome:
name: tmpl-clim-custom
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Custom Mode Climate
optimistic: true
sensor: test_climate_current_temperature
supported_modes:
- "OFF"
- HEAT
- COOL
custom_fan_modes:
- turbo
- silent
- eco
custom_presets:
- eco_plus
- power_save
- max
on_control:
- lambda: |-
if (x.has_custom_fan_mode())
ESP_LOGD("test", "on_control custom_fan_mode=%s", x.get_custom_fan_mode().c_str());
if (x.has_custom_preset())
ESP_LOGD("test", "on_control custom_preset=%s", x.get_custom_preset().c_str());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 22.5f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
custom_fan_mode: "eco"
custom_preset: "max"
@@ -0,0 +1,56 @@
esphome:
name: tmpl-clim-nonopt
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Template Climate Nonoptimistic
optimistic: false
supported_modes:
- "OFF"
- HEAT
- COOL
- FAN_ONLY
supported_fan_modes:
- AUTO
- LOW
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
- AWAY
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature().has_value())
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
if (x.get_fan_mode().has_value())
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
if (x.get_swing_mode().has_value())
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
if (x.get_preset().has_value())
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
button:
- platform: template
id: simulate_device_confirmation
name: Simulate Device Confirmation
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT
target_temperature: 22.5
fan_mode: HIGH
swing_mode: VERTICAL
preset: AWAY
@@ -0,0 +1,26 @@
esphome:
name: tmpl-clim-oc-order
host:
api:
logger:
# on_control fires with the full ClimateCall (arg `x`) from the base Climate component's
# ClimateCall::perform(), before validate_()/control() run -- so when the lambda action below
# runs, the entity's own .mode is still the OLD value, even though x.get_mode() already reports
# the NEW requested value. on_state fires afterward, once control() has applied it.
climate:
- platform: template
id: test_climate
name: Test On Control Ordering
optimistic: true
supported_modes:
- "OFF"
- HEAT
on_control:
- lambda: |-
ESP_LOGD("test", "on_control requested_mode=%d current_mode_before_apply=%d",
x.get_mode().has_value() ? (int) *x.get_mode() : -1,
(int) id(test_climate).mode);
on_state:
- lambda: |-
ESP_LOGD("test", "on_state mode=%d", (int) x.mode);
@@ -0,0 +1,63 @@
esphome:
name: tmpl-clim-publish-all
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Publish All Fields
optimistic: true
# current_temperature/current_humidity/action are only sent over the API at all if their
# trait is advertised: current_temperature/current_humidity because a sensor/humidity_sensor
# is referenced below, action because supports_action is set. The sensors' fixed readings
# match what climate.template.publish pushes, so the sensor callback (guarded to only publish
# on an actual change) doesn't produce an extra, unexpected state update of its own.
sensor: test_climate_current_temperature
humidity_sensor: test_climate_current_humidity
supports_action: true
supported_modes:
- "OFF"
- HEAT
supported_fan_modes:
- AUTO
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
on_control:
# Should never fire in this test: climate.template.publish is a pure bypass and must not
# re-trigger on_control as if the entity were freshly commanded.
- logger.log: "on_control fired"
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 20.0f;"
update_interval: 10ms
- platform: template
id: test_climate_current_humidity
name: Test Climate Current Humidity
lambda: "return 60.0f;"
update_interval: 10ms
button:
- platform: template
id: publish_all
name: Publish All
on_press:
- climate.template.publish:
id: test_climate
current_temperature: 20.0
current_humidity: 60.0
target_temperature: 23.0
mode: HEAT
action: HEATING
fan_mode: HIGH
swing_mode: VERTICAL
preset: ECO
@@ -0,0 +1,49 @@
esphome:
name: tmpl-clim-sensor-push
host:
api:
logger:
# No lambda/update_interval: these sensors only ever report a value when a button below
# publishes one (standing in for e.g. a BLE scan callback in a real config).
sensor:
- platform: template
id: room_temperature
name: Room Temperature
- platform: template
id: room_humidity
name: Room Humidity
climate:
- platform: template
id: test_climate
name: Test Sensor Push Climate
optimistic: true
sensor: room_temperature
humidity_sensor: room_humidity
supported_modes:
- "OFF"
- HEAT
button:
- platform: template
id: publish_temperature
name: Publish Temperature
on_press:
- sensor.template.publish:
id: room_temperature
state: 24.0
- platform: template
id: publish_temperature_same
name: Publish Temperature Same Value
on_press:
- sensor.template.publish:
id: room_temperature
state: 24.0
- platform: template
id: publish_humidity
name: Publish Humidity
on_press:
- sensor.template.publish:
id: room_humidity
state: 65.0
@@ -0,0 +1,89 @@
esphome:
name: tmpl-clim-set-act
host:
api:
logger:
# Every settable field forwards its requested value to a set_*_action. supports_two_point and
# supports_target_humidity are not declared here: they are derived from the low/high and humidity
# set actions being present.
climate:
- platform: template
id: test_climate
name: Test Set Actions
optimistic: false
restore_mode: NO_RESTORE
supported_modes:
- "OFF"
- HEAT
- COOL
supported_fan_modes:
- AUTO
- LOW
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
custom_fan_modes:
- turbo
custom_presets:
- eco_plus
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
set_mode_action:
- logger.log:
format: "set_mode_action %d"
args: ["(int) x"]
set_target_temperature_low_action:
- logger.log:
format: "set_target_temperature_low_action %.1f"
args: ["x"]
set_target_temperature_high_action:
- logger.log:
format: "set_target_temperature_high_action %.1f"
args: ["x"]
set_target_humidity_action:
- logger.log:
format: "set_target_humidity_action %.0f"
args: ["x"]
set_fan_mode_action:
- logger.log:
format: "set_fan_mode_action %d"
args: ["(int) x"]
set_custom_fan_mode_action:
- logger.log:
format: "set_custom_fan_mode_action %s"
args: ["x.c_str()"]
set_swing_mode_action:
- logger.log:
format: "set_swing_mode_action %d"
args: ["(int) x"]
set_preset_action:
- logger.log:
format: "set_preset_action %d"
args: ["(int) x"]
set_custom_preset_action:
- logger.log:
format: "set_custom_preset_action %s"
args: ["x.c_str()"]
button:
- platform: template
id: report_device_state
name: Report Device State
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT
- platform: template
id: report_unsupported_mode
name: Report Unsupported Mode
on_press:
- climate.template.publish:
id: test_climate
mode: DRY
@@ -0,0 +1,52 @@
esphome:
name: tmpl-clim-two-point
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Two-Point Heatpump
optimistic: true
sensor: test_climate_current_temperature
supports_two_point_target_temperature: true
supports_target_humidity: true
supported_modes:
- "OFF"
- HEAT_COOL
- HEAT
- COOL
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature_low().has_value())
ESP_LOGD("test", "on_control target_temperature_low=%.1f", *x.get_target_temperature_low());
if (x.get_target_temperature_high().has_value())
ESP_LOGD("test", "on_control target_temperature_high=%.1f", *x.get_target_temperature_high());
if (x.get_target_humidity().has_value())
ESP_LOGD("test", "on_control target_humidity=%.1f", *x.get_target_humidity());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 21.0f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT_COOL
target_temperature_low: 18.0
target_temperature_high: 24.0
target_humidity: 50.0
@@ -0,0 +1,150 @@
esphome:
name: uart-mock-modbus-broadcast
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: true # controller polls at boot; forwarding must already be active
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
globals:
- id: srv1_reg
type: int
initial_value: "0"
- id: srv2_reg
type: int
initial_value: "0"
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
# Polling is off until the test has subscribed; the Start Scenario button starts it, so the
# first poll is never lost to a boot-time race ahead of the API subscription.
update_interval: never
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 0x10
value_type: U_WORD
read_lambda: return id(srv1_reg);
write_lambda: |-
id(srv1_reg) = x;
return true;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(srv2_reg);
write_lambda: |-
id(srv2_reg) = x;
return true;
sensor:
# Normal polling continues before and after the broadcast: the old behavior burned a
# timeout per broadcast, which surfaces as modbus warnings and failed expectations here.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
# Republish every poll (the value is constant 919): the test observes successive publishes to
# prove polling continues before and after the broadcast, which dedup would otherwise hide.
force_update: true
# The servers' written values, published locally.
- platform: template
name: "srv1_written"
lambda: return id(srv1_reg);
update_interval: 0.2s
- platform: template
name: "srv2_written"
lambda: return id(srv2_reg);
update_interval: 0.2s
# Whether the hub accepted the broadcast into the transmit queue (the bool queue_pdu() returns).
- platform: template
name: "broadcast_accepted"
id: broadcast_accepted
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
// Start polling now that the test has subscribed.
id(modbus_controller_1).set_update_interval(1000);
id(modbus_controller_1).start_poller();
// Broadcast (address 0) write single register: reg 0x10 = 777 on every server.
// PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC.
const uint8_t pdu[] = {0x06, 0x00, 0x10, 0x03, 0x09};
// queue_pdu() returns whether the broadcast was accepted into the machine (the answer this PR
// makes meaningful); publish it so the test asserts the accept, not just the servers' writes.
bool accepted = id(virtual_modbus_client)->queue_pdu(0x00, pdu);
id(broadcast_accepted).publish_state(accepted ? 1.0f : 0.0f);
@@ -0,0 +1,108 @@
esphome:
name: uart-mock-modbus-client-inline
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: 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;
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
# Short wait so the no-reply cases (address 2 below) time out well within the test window.
send_wait_time: 500ms
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return 1234;
sensor:
- platform: template
name: "inline_value"
id: inline_value
- platform: template
name: "timeout_flag"
id: timeout_flag
- platform: template
name: "skipped_flag"
id: skipped_flag
# The same write action fired twice while its first frame is still awaiting a reply: the hub drops the
# duplicate write (writes are never merged) and the second firing resolves via its own on_not_sent.
# mode: parallel so the second run starts while the first send is pending.
script:
- id: dup_write
mode: parallel
then:
- modbus_client.send:
address: 2
pdu: [0x06, 0x00, 0x10, 0x01, 0x02]
on_not_sent:
then:
- lambda: "id(skipped_flag).publish_state(1);"
# Each action is its own hub device: address 1 is served by the mock server, address 2 answers nothing.
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# Per-send inline on_response: decode this reply where the send was fired (fire-and-continue).
- modbus_client.send:
address: 1
pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
on_response:
then:
- lambda: |-
if (response.size() >= 4)
id(inline_value).publish_state((response[2] << 8) | response[3]);
# No server answers address 2, so this resolves via on_no_response.
- modbus_client.send:
address: 2
pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
on_no_response:
then:
- lambda: "id(timeout_flag).publish_state(1);"
- script.execute: dup_write
- script.execute: dup_write
@@ -0,0 +1,176 @@
esphome:
name: uart-mock-modbus-client-typed
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: 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;
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
globals:
- id: reg10
type: uint16_t
initial_value: "0"
- id: reg11
type: uint16_t
initial_value: "0"
- id: reg12
type: uint16_t
initial_value: "0"
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: |-
id(reg11) = x;
return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: |-
id(reg12) = x;
return true;
sensor:
- platform: template
name: "typed_value"
id: typed_value
- platform: template
name: "ack_flag"
id: ack_flag
- platform: template
name: "error_code"
id: error_code
- platform: template
name: "coil_error_code"
id: coil_error_code
- platform: template
name: "multi_value"
id: multi_value
- platform: template
name: "multi_coil_error"
id: multi_coil_error
- platform: template
name: "not_sent_flag"
id: not_sent_flag
# Typed actions end to end: a typed write lands on the server (ack -> ack_flag), the typed read-back
# decodes the written value from the reply words (values[0] -> typed_value), and a read of an unserved
# register resolves via on_error with the device's exception code (-> error_code).
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- modbus_client.write_single_register:
address: 1
start_address: 0x10
value: 777
on_response:
then:
- lambda: "id(ack_flag).publish_state(1);"
- modbus_client.read_holding_registers:
address: 1
start_address: 0x10
on_response:
then:
- lambda: |-
if (!values.empty())
id(typed_value).publish_state(values[0]);
- modbus_client.read_holding_registers:
address: 1
start_address: 0x99
on_error:
then:
- lambda: "id(error_code).publish_state((int) exception_code);"
# 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
count: 8
on_error:
then:
- lambda: "id(coil_error_code).publish_state((int) exception_code);"
# Multi-register write (fc 0x10, served) then read-back of the second written register.
- modbus_client.write_multiple_registers:
address: 1
start_address: 0x11
values: [111, 222]
on_response:
then:
- modbus_client.read_holding_registers:
address: 1
start_address: 0x12
on_response:
then:
- lambda: |-
if (!values.empty())
id(multi_value).publish_state(values[0]);
# A count lambda can go out of spec at runtime: the builder rejects it into an empty PDU, the hub
# refuses that at the door, and the send resolves via on_not_sent (no reply will ever come).
- modbus_client.read_holding_registers:
address: 1
start_address: 0x10
count: !lambda "return 0;"
on_not_sent:
then:
- lambda: "id(not_sent_flag).publish_state(1);"
# 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
values: [true, false, true]
on_error:
then:
- lambda: "id(multi_coil_error).publish_state((int) exception_code);"
@@ -0,0 +1,115 @@
esphome:
name: uart-mock-modbus-continuous
host:
api:
logger:
level: VERBOSE
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
globals:
- id: silence_server
type: bool
initial_value: "false"
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- if:
condition:
lambda: "return !id(silence_server);"
then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
# replies arrive within turnaround_time, so this does not slow the streaming path.
send_wait_time: 100ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
# A long update_interval means that without continuous polling only the boot poll would run in the
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
# bus allows.
update_interval: 30s
continuous: true
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
max_cmd_retries: 1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
# Each read returns the next counter value, so every poll publishes a distinct state the test can
# count (proving the read actually ran, not just that the state changed once).
- address: 0x01
value_type: U_WORD
read_lambda: |-
static uint16_t counter = 0;
return counter++;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "continuous_reg"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
on_press:
- lambda: "id(modbus_controller_1)->update();"
switch:
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
- platform: template
name: "Silence Server"
id: silence_server_switch
optimistic: true
turn_on_action:
- lambda: "id(silence_server) = true;"
turn_off_action:
- lambda: "id(silence_server) = false;"
@@ -0,0 +1,125 @@
esphome:
name: uart-mock-modbus-fairness
host:
api:
logger:
# DEBUG (not VERBOSE) keeps the log volume manageable while both controllers
# hammer the bus at a high rate.
level: DEBUG
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
# Counters for the number of requests seen on the bus for each device address.
globals:
- id: req_count_1
type: int
initial_value: "0"
- id: req_count_2
type: int
initial_value: "0"
uart_mock:
- id: virtual_uart
baud_rate: 9600
# auto_start so the mock is ready to deliver injected responses. Polling
# itself is gated by the Start button below.
auto_start: true
debug:
on_tx:
- then:
# Count each outgoing request by device address (byte 0 of the frame).
- lambda: |-
if (data.empty())
return;
if (data[0] == 0x01) {
id(req_count_1) += 1;
id(requests_1).publish_state(id(req_count_1));
} else if (data[0] == 0x02) {
id(req_count_2) += 1;
id(requests_2).publish_state(id(req_count_2));
}
# Reply directly with a canned, CRC-correct "read holding register"
# response for whichever device was addressed (both controllers only
# ever issue this one fixed request, so the responses are constant).
- uart_mock.inject_rx:
id: virtual_uart
data: !lambda |-
if (!data.empty() && data[0] == 0x01)
return {0x01, 0x03, 0x02, 0x00, 0x6F, 0xF8, 0x68}; // value 111
if (!data.empty() && data[0] == 0x02)
return {0x02, 0x03, 0x02, 0x00, 0xDE, 0x7C, 0x1C}; // value 222
return {};
modbus:
- uart_id: virtual_uart
id: virtual_modbus_client
role: client
turnaround_time: 15ms #This is longer than the polling interval to cause contention
# Two controllers sharing one client bus, each polling a different device.
# Polling is started by the test (update_interval: never until then) so counting
# only begins once the API client has subscribed.
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
id: modbus_controller_1
update_interval: never
- address: 2
modbus_id: virtual_modbus_client
id: modbus_controller_2
update_interval: never
sensor:
# These sensors define the register range each controller polls (and so drive
# the requests). Their values are not checked by the test.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: reg_1
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: reg_2
address: 0x01
register_type: holding
value_type: U_WORD
# Request counters exposed to the test. Updated manually from the on_tx hook.
- platform: template
name: requests_1
id: requests_1
update_interval: never
- platform: template
name: requests_2
id: requests_2
update_interval: never
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
// Poll much faster than the bus can service so both controllers always
// have a request pending and must contend for the bus.
id(modbus_controller_1).set_update_interval(10);
id(modbus_controller_1).start_poller();
id(modbus_controller_2).set_update_interval(10);
id(modbus_controller_2).start_poller();
- platform: template
name: "Stop Scenario"
id: stop_scenario_btn
on_press:
- lambda: |-
id(modbus_controller_1).stop_poller();
id(modbus_controller_2).stop_poller();
@@ -0,0 +1,235 @@
esphome:
name: uart-mock-modbus-group
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
responses:
# One entry per range the controller polls. A frame the controller does not send goes unanswered,
# so these also pin the grouping: an extra or differently shaped read fails the test.
- expect_tx: [0x01, 0x01, 0x00, 0x10, 0x00, 0x02, 0xBC, 0x0E] # coils 0x10 count 2
inject_rx: [0x01, 0x01, 0x01, 0x01, 0x90, 0x48] # bit0 set, bit1 clear
- expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x02, 0xC5, 0xE9] # holding 0x160 count 2
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x60, 0x01, 0x61, 0x3B, 0xA9] # 352, 353
- expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x85, 0xF6] # holding 0x100 count 1
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x11, 0x02, 0x22, 0x2A, 0xB3] # 4 bytes: 273 then 546
- expect_tx: [0x01, 0x03, 0x01, 0x20, 0x00, 0x04, 0x44, 0x3F] # holding 0x120 count 4
inject_rx: [0x01, 0x03, 0x08, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x7A, 0x25]
- expect_tx: [0x01, 0x03, 0x01, 0x30, 0x00, 0x02, 0xC5, 0xF8] # holding 0x130 count 2
inject_rx: [0x01, 0x03, 0x06, 0x0A, 0xAA, 0xFF, 0xFF, 0x0B, 0xBB, 0x7E, 0xA0] # 6 bytes
- expect_tx: [0x01, 0x03, 0x01, 0x40, 0x00, 0x01, 0x84, 0x22] # holding 0x140 count 1
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x40, 0xB8, 0x24] # 320
- expect_tx: [0x01, 0x03, 0x01, 0x45, 0x00, 0x01, 0x94, 0x23] # holding 0x145 count 1
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x45, 0x78, 0x27] # 325
- expect_tx: [0x01, 0x03, 0x01, 0x50, 0x00, 0x02, 0xC5, 0xE6] # holding 0x150 count 2
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x50, 0x01, 0x51, 0x3B, 0xB2] # 336, 337
- expect_tx: [0x01, 0x03, 0x01, 0x80, 0x00, 0x02, 0xC4, 0x1F] # holding 0x180 count 2
inject_rx: [0x01, 0x03, 0x06, 0x11, 0x11, 0x22, 0x22, 0x33, 0x33, 0x20, 0xA0] # 6 bytes
# 0x181 answers with the same value whether it is read on its own or as part of the block above,
# so the sensor there is pinned to one value regardless of which range it lands in.
- expect_tx: [0x01, 0x03, 0x01, 0x81, 0x00, 0x01, 0xD5, 0xDE] # holding 0x181 count 1
inject_rx: [0x01, 0x03, 0x02, 0x33, 0x33, 0xEC, 0xA1] # 13107
- expect_tx: [0x01, 0x03, 0x01, 0x70, 0x00, 0x03, 0x05, 0xEC] # holding 0x170 count 3
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x2A, 0x1B, 0x2C, 0x03, 0x0D, 0x3E, 0xAB] # 6 bytes
modbus:
uart_id: virtual_uart_dev
send_wait_time: 200ms
turnaround_time: 10ms
modbus_controller:
- address: 1
id: modbus_controller_ok
max_cmd_retries: 2
update_interval: never
# Each block below is a distinct address range exercising one grouping relationship. The blocks are far
# enough apart that they never merge into each other.
sensor:
# A - two sensors on one register that returns more bytes than its count implies (response_size),
# reading different halves of it.
- platform: modbus_controller
name: "reuse_lo"
address: 0x100
register_type: holding
value_type: U_WORD
response_size: 4
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "reuse_hi"
address: 0x100
register_type: holding
value_type: U_WORD
offset: 2
response_size: 4
modbus_controller_id: modbus_controller_ok
# C - plain contiguous registers of differing widths.
- platform: modbus_controller
name: "ext_word"
address: 0x120
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "ext_next"
address: 0x121
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "ext_dword"
address: 0x122
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
# D - a wide (response_size) register followed by a contiguous reuse:true one (auto never joins past
# a response_size register): the follower must start after the bytes the wide register actually
# returned, not after two per register.
- platform: modbus_controller
name: "wide_first"
address: 0x130
register_type: holding
value_type: U_WORD
response_size: 4
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "wide_next"
address: 0x131
register_type: holding
value_type: U_WORD
reuse_previous_range: true
modbus_controller_id: modbus_controller_ok
# E - a gap: these must never share a range.
- platform: modbus_controller
name: "gap_low"
address: 0x140
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "gap_high"
address: 0x145
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# F - contiguous registers that historically carried differing skip_updates rates and were split by
# the rate merge; with per-range rates gone they group like any contiguous pair.
- platform: modbus_controller
name: "rate_first"
address: 0x150
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "rate_slow"
address: 0x151
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# B - a wide value and one of its halves share a start address, with a contiguous sensor after them.
# The differing offsets give these a defined order, unlike two sensors that differ only in width.
- platform: modbus_controller
name: "shared_dword"
address: 0x170
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "shared_high"
address: 0x170
register_type: holding
value_type: U_WORD
offset: 2
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "shared_after"
address: 0x172
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# I - a register that returns more bytes than its count implies, sharing its address with a plain
# wider sensor. Whether the sensor after them is read as part of that block or on its own, it must
# decode 0x181 - never the bytes that lie two into the block, which is where the widened register
# count alone would put it.
- platform: modbus_controller
name: "masked_wide"
address: 0x180
register_type: holding
value_type: U_WORD
response_size: 4
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "masked_pair"
address: 0x180
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "masked_after"
address: 0x181
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# H - a sensor that never joins the range built before it (reuse_previous_range: false), followed
# by a contiguous plain item that extends the new range it started.
- platform: modbus_controller
name: "forced_first"
address: 0x160
register_type: holding
value_type: U_WORD
reuse_previous_range: false
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "forced_next"
address: 0x161
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
binary_sensor:
# G - contiguous coils, addressed by bit.
- platform: modbus_controller
name: "coil_first"
address: 0x10
register_type: coil
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "coil_next"
address: 0x11
register_type: coil
modbus_controller_id: modbus_controller_ok
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(modbus_controller_ok).set_update_interval(1000);
id(modbus_controller_ok).start_poller();
@@ -0,0 +1,233 @@
esphome:
name: uart-mock-modbus-loopback
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
# Shared loopback fixture (see the shared_yaml markers in the test file);
# register spaces are disjoint so each test only observes its own entities.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
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
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
- id: reg30
type: uint16_t
initial_value: "0"
- id: reg40
type: uint16_t
initial_value: "5"
- id: reg50
type: uint16_t
initial_value: "0"
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
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
- address: 0x50
value_type: U_WORD
read_lambda: return id(reg50);
write_lambda: id(reg50) = x; return true;
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
# register count, hitting 0x12). assumed_state keeps the switch write-only.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
# would draw ILLEGAL_DATA_ADDRESS and never publish.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
# returning an empty optional suppresses the default coil write.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
# Active-low: the write_lambda inverts the wire value but the entity must still
# report the requested state (assumed_state keeps the register unpolled).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
# must land and the deprecation warning must fire only once per entity.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x50
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
payload.push_back(0x0106);
payload.push_back(0x0050);
payload.push_back((uint16_t) x);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001; device address and CRC are added
# by the hub. The lambda parses the big-endian register value.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg50);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Nothing to start (mock is autostart); tests drive entities directly
@@ -0,0 +1,302 @@
esphome:
name: uart-mock-modbus-mesh
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
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second
# server hub. auto_start everywhere: the controller polls at boot, so the
# forwarding must already be live or early requests generate warnings.
# Every test presses Start Scenario, so all merged actions fire in every test.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
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_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
id: modbus_controller_1
update_interval: 1s
- address: 2
modbus_id: virtual_modbus_client
id: modbus_controller_2
update_interval: 1s
- address: 3
modbus_id: virtual_modbus_client
id: modbus_controller_3
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 99;
- address: 0x02
value_type: U_WORD_S
read_lambda: return 4660;
- address: 0x03
value_type: S_WORD
read_lambda: return -99;
- address: 0x04
value_type: S_WORD_S
read_lambda: return -2;
- address: 0x05
value_type: U_DWORD
read_lambda: return 16909060;
- address: 0x08
value_type: S_DWORD
read_lambda: return -16909060;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return 67305985;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return -67305985;
- address: 0x11
value_type: U_QWORD
read_lambda: return 72623859790382856;
- address: 0x16
value_type: S_QWORD
read_lambda: return -72623859790382856;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return 578437695752307201;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return -578437695752307201;
- address: 0x25
value_type: FP32
read_lambda: return 3.14;
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
- address: 5
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: srv_write_1 plus the client's read-back
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: 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;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 3
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s_raw"
address: 0x02
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "multi_reg_a"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_3
name: "multi_reg_b"
address: 0x01
register_type: holding
value_type: U_WORD
# client_read_write observations, server- and client-side.
- platform: template
name: "srv_write_1"
id: srv_write_1
- 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: 5
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]);
}
@@ -0,0 +1,93 @@
esphome:
name: uart-mock-modbus-offline
host:
api:
logger:
level: DEBUG
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
# Whether the mock device answers requests. Starts false so the controller
# runs through its retries and goes offline; the test flips it via the
# "Serve" button to exercise the offline retry/recovery path.
globals:
- id: serve
type: bool
initial_value: "false"
uart_mock:
- id: virtual_uart
baud_rate: 9600
auto_start: true
debug:
on_tx:
# While serve is false every request times out; once true, answer the
# (only) request - read holding register 3 on device 1 - with value 259.
- uart_mock.inject_rx:
id: virtual_uart
data: !lambda |-
if (!id(serve))
return {};
return {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5};
modbus:
- uart_id: virtual_uart
id: virtual_modbus_client
send_wait_time: 100ms
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
id: ctl
max_cmd_retries: 1
# offline_skip_updates: 1 -> once offline, the controller re-probes every second update cycle;
# the test silences the device to force it offline, then answers again and checks it recovers.
offline_skip_updates: 1
update_interval: never
on_offline:
then:
- lambda: id(link_state).publish_state(0);
on_online:
then:
- lambda: id(link_state).publish_state(1);
sensor:
- platform: modbus_controller
modbus_controller_id: ctl
name: reg
id: reg
address: 0x03
register_type: holding
value_type: U_WORD
# Mirrors the controller's online state so the test can await the transitions.
- platform: template
name: link_state
id: link_state
update_interval: never
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
id(ctl).set_update_interval(200);
id(ctl).start_poller();
- platform: template
name: "Serve"
id: serve_btn
on_press:
- globals.set:
id: serve
value: "true"
@@ -0,0 +1,227 @@
esphome:
name: uart-mock-modbus-ranges-test
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
# Each expect_tx below pins the exact read request the controller's range builder emits, so this
# fixture is a wire-level test of reuse_previous_range (auto/yes/no), gap joins, same-register reuse,
# response_size surplus accounting, and RAW/text block reads.
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
# auto_start must be false to avoid races: the test presses the
# "Start Scenario" button only after subscribing to states.
auto_start: false
debug:
responses:
- expect_tx: [0x01, 0x03, 0x00, 0x00, 0x00, 0x03, 0x05, 0xCB] # auto adjacency: one read covers 0x00-0x02
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0xFD, 0x74]
- expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # auto gap: 0x10 alone
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x04, 0xB9, 0x87]
- expect_tx: [0x01, 0x03, 0x00, 0x13, 0x00, 0x01, 0x75, 0xCF] # auto gap: 0x13 alone
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x05, 0x78, 0x47]
- expect_tx: [0x01, 0x03, 0x00, 0x20, 0x00, 0x04, 0x45, 0xC3] # yes across gap: one read 0x20-0x23, gap registers ignored
inject_rx: [0x01, 0x03, 0x08, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x33, 0xD1]
- expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # no isolation: 0x30 alone despite adjacency
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0A, 0x38, 0x43]
- expect_tx: [0x01, 0x03, 0x00, 0x31, 0x00, 0x01, 0xD5, 0xC5] # no isolation: 0x31 alone
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0B, 0xF9, 0x83]
- expect_tx: [0x01, 0x03, 0x00, 0x3F, 0x00, 0x01, 0xB4, 0x06] # open NEVER: 0x3F alone (the reuse:false item split off)
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0C, 0xB8, 0x41]
- expect_tx: [0x01, 0x03, 0x00, 0x40, 0x00, 0x02, 0xC5, 0xDF] # open NEVER: 0x40 (reuse: false) still extended by the auto item at 0x41
inject_rx: [0x01, 0x03, 0x04, 0x00, 0x0D, 0x00, 0x0E, 0xEA, 0x34]
- expect_tx: [0x01, 0x03, 0x00, 0x50, 0x00, 0x01, 0x84, 0x1B] # same-address reuse: one read, two sensors on 0x50
inject_rx: [0x01, 0x03, 0x02, 0x12, 0x34, 0xB5, 0x33]
- expect_tx: [0x01, 0x03, 0x00, 0x60, 0x00, 0x04, 0x44, 0x17] # text block + adjacent word: one read 0x60-0x63
inject_rx: [0x01, 0x03, 0x08, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x00, 0x0F, 0x78, 0x0E]
- expect_tx: [0x01, 0x03, 0x00, 0x70, 0x00, 0x02, 0xC5, 0xD0] # response_size surplus: 0x70 answers 4 bytes, reuse:true word at 0x71 shifted along
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x10, 0xAA, 0xBB, 0x00, 0x11, 0x71, 0x47]
- expect_tx: [0x01, 0x03, 0x00, 0x90, 0x00, 0x01, 0x84, 0x27] # auto after surplus: 0x90 alone (auto never joins past response_size)
inject_rx: [0x01, 0x03, 0x04, 0x00, 0x18, 0xCC, 0xDD, 0xEF, 0x6D]
- expect_tx: [0x01, 0x03, 0x00, 0x91, 0x00, 0x01, 0xD5, 0xE7] # auto after surplus: 0x91 alone
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x19, 0x79, 0x8E]
- expect_tx: [0x01, 0x03, 0x00, 0x80, 0x00, 0x04, 0x45, 0xE1] # RAW block via response_size: 8 bytes = 4 registers in one read
inject_rx: [0x01, 0x03, 0x08, 0x00, 0x14, 0x00, 0x15, 0x00, 0x16, 0x00, 0x17, 0x6D, 0xDF]
modbus:
uart_id: virtual_uart_dev
send_wait_time: 200ms
turnaround_time: 10ms
modbus_controller:
- address: 1
id: ranges_controller
max_cmd_retries: 0
# The test triggers a single poll by pressing the "Start Scenario" button
update_interval: never
sensor:
# Case 1: three adjacent registers merge into one read (auto default)
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "adjacent_a"
register_type: holding
address: 0x00
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "adjacent_b"
register_type: holding
address: 0x01
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "adjacent_c"
register_type: holding
address: 0x02
# Case 2: a gap keeps auto items apart
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "gap_a"
register_type: holding
address: 0x10
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "gap_b"
register_type: holding
address: 0x13
# Case 3: reuse_previous_range: true bridges the gap into one read
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "bridge_a"
register_type: holding
address: 0x20
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "bridge_b"
register_type: holding
address: 0x23
reuse_previous_range: true
# Case 4: reuse_previous_range: false splits adjacent registers
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "split_a"
register_type: holding
address: 0x30
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "split_b"
register_type: holding
address: 0x31
reuse_previous_range: false
# Case 5: a reuse:false item starts its own range but stays open for later auto items
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "open_prev"
register_type: holding
address: 0x3F
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "open_never"
register_type: holding
address: 0x40
reuse_previous_range: false
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "open_tagalong"
register_type: holding
address: 0x41
# Case 6: two sensors on the same register share one read
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "shared_lo"
register_type: holding
address: 0x50
bitmask: 0x00FF
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "shared_hi"
register_type: holding
address: 0x50
bitmask: 0xFF00
# Case 10 (text block, see text_sensor below) shares the range with this word at 0x63
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "after_text"
register_type: holding
address: 0x63
# Case 11: response_size surplus — the device answers 4 bytes for this single register, so the
# following sensor's data sits 2 bytes later than its address alone implies. Joining past a
# non-standard response_size takes an explicit reuse_previous_range: true.
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "surplus"
register_type: holding
address: 0x70
response_size: 4
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "after_surplus"
register_type: holding
address: 0x71
reuse_previous_range: true
# Case 13: auto never joins past a response_size register — despite adjacency these poll separately
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "surplus_split"
register_type: holding
address: 0x90
response_size: 4
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "after_surplus_split"
register_type: holding
address: 0x91
# Case 12: RAW + response_size reads a block of ceil(8/2) = 4 registers
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "raw_block"
register_type: holding
address: 0x80
value_type: RAW
response_size: 8
lambda: |-
return (float) data.size();
text_sensor:
# Case 10: text sensor reads 3 registers (response_size 6)
- platform: modbus_controller
modbus_controller_id: ranges_controller
name: "text_block"
register_type: holding
address: 0x60
response_size: 6
raw_encode: NONE
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(ranges_controller).set_update_interval(1000);
id(ranges_controller).start_poller();
@@ -1,124 +0,0 @@
esphome:
name: uart-mock-modbus-server-test
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_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2
# Reply from device 2
# Read holding register 5 on device 1 (read_after_peer_response)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x02,
0x03,
0x02,
0x00,
0xF0,
0xFC,
0x00,
0x01,
0x03,
0x00,
0x05,
0x00,
0x01,
0x94,
0x0B,
]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2, with no response
# Read holding register A on device 1 (read_after_peer_timeout)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x01,
0x03,
0x00,
0x0A,
0x00,
0x01,
0xA4,
0x08,
]
modbus:
uart_id: virtual_uart_dev
role: server
modbus_server:
- address: 1
registers:
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-server-contro
name: uart-mock-modbus-srv-bits
host:
api:
@@ -41,6 +41,14 @@ uart_mock:
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
@@ -53,124 +61,84 @@ modbus:
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
value_type: U_WORD
read_lambda: return 99;
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
value_type: S_WORD
read_lambda: return -99;
- address: 0x05
value_type: U_DWORD
read_lambda: return 16909060;
- address: 0x08
value_type: S_DWORD
read_lambda: return -16909060;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return 67305985;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return -67305985;
- address: 0x11
value_type: U_QWORD
read_lambda: return 72623859790382856;
- address: 0x16
value_type: S_QWORD
read_lambda: return -72623859790382856;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return 578437695752307201;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return -578437695752307201;
- address: 0x25
value_type: FP32
read_lambda: return 3.14;
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
sensor:
# 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: "reg_u_word"
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: holding
value_type: U_WORD
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
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: holding
value_type: S_WORD
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
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: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button:
- platform: template
@@ -1,116 +0,0 @@
esphome:
name: uart-mock-modbus-server-mult
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;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
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;
- 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;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_1
- address: 2
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_2
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 2
modbus_id: virtual_modbus_server_2
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "reg_u_word_2"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -45,9 +45,15 @@ globals:
- id: stored_u_word
type: uint16_t
initial_value: "11"
- id: stored_u_word_s
type: uint16_t
initial_value: "4660"
- id: stored_s_word
type: int16_t
initial_value: "-11"
- id: stored_s_word_s
type: int16_t
initial_value: "-2"
- id: stored_u_dword
type: uint32_t
initial_value: "1001"
@@ -103,10 +109,18 @@ modbus_server:
value_type: U_WORD
read_lambda: return id(stored_u_word);
write_lambda: id(stored_u_word) = x; return true;
- address: 0x02
value_type: U_WORD_S
read_lambda: return id(stored_u_word_s);
write_lambda: id(stored_u_word_s) = x; return true;
- address: 0x03
value_type: S_WORD
read_lambda: return id(stored_s_word);
write_lambda: id(stored_s_word) = x; return true;
- address: 0x04
value_type: S_WORD_S
read_lambda: return id(stored_s_word_s);
write_lambda: id(stored_s_word_s) = x; return true;
- address: 0x05
value_type: U_DWORD
read_lambda: return id(stored_u_dword);
@@ -155,12 +169,24 @@ sensor:
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
@@ -231,6 +257,14 @@ number:
value_type: U_WORD
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word"
@@ -239,6 +273,14 @@ number:
value_type: S_WORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword"
@@ -0,0 +1,145 @@
esphome:
name: uart-mock-modbus-srv-injected
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
# Shared server-role fixture (see the shared_yaml markers in the test file);
# the injections concatenate and each test waits only on its own sensors.
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2, its reply, then read holding
# register 5 on device 1 (read_after_peer_response)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2 with no response, then read
# holding register A on device 1 (read_after_peer_timeout)
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
# a write and read targeting a different register block.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
- id: stored_3
type: uint16_t
initial_value: "0"
modbus:
uart_id: virtual_uart_dev
role: server
modbus_server:
- address: 1
registers:
# Writable + readable register backed by a global. The read publishes what it
# returns so the test can confirm the write half ran before the read half.
- address: 0x01
value_type: U_WORD
read_lambda: |-
id(rw_read_1).publish_state(id(stored_1));
return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(rw_write_1).publish_state(x);
return true;
# Read-only register, read together with 0x01 by the first request's 2-register read.
- address: 0x02
value_type: U_WORD
read_lambda: |-
id(rw_read_2).publish_state(0x00AA);
return 0x00AA;
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
# Second writable + readable register, targeted by the second FC 0x17 request.
- address: 0x06
value_type: U_WORD
read_lambda: |-
id(rw_read_3).publish_state(id(stored_3));
return id(stored_3);
write_lambda: |-
id(stored_3) = x;
id(rw_write_3).publish_state(x);
return true;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
- platform: template
name: "rw_write_1"
id: rw_write_1
- platform: template
name: "rw_read_1"
id: rw_read_1
- platform: template
name: "rw_read_2"
id: rw_read_2
- platform: template
name: "rw_write_3"
id: rw_write_3
- platform: template
name: "rw_read_3"
id: rw_read_3
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -0,0 +1,81 @@
esphome:
name: uart-mock-modbus-srv-rw-inv
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_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
# Malformed FC 0x17 Read/Write Multiple Registers, otherwise well formed (valid CRC): write
# quantity 2 but byte count 2 (2 registers need 4 bytes), i.e. byte count != 2x write quantity.
# The hub must reject it (ILLEGAL_DATA_VALUE) before touching any register.
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02, 0x02, 0x12, 0x34, 0x09, 0x89]
# A valid FC 0x03 read of reg 0x0A injected afterwards. Its read_lambda fires the "probe"
# sensor, which (because injections run in order) signals the malformed frame was processed.
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
modbus:
uart_id: virtual_uart_dev
role: server
modbus_server:
- address: 1
registers:
# The malformed request's write half spans 0x01-0x02. Both are registered so modbus_server's
# address pre-flight cannot reject the frame on its own: if the hub wrongly accepted it, these
# write_lambdas would fire the "write_seen" sensor.
- address: 0x01
value_type: U_WORD
read_lambda: return 0;
write_lambda: |-
id(write_seen).publish_state(1);
return true;
- address: 0x02
value_type: U_WORD
read_lambda: return 0;
write_lambda: |-
id(write_seen).publish_state(1);
return true;
# Processing probe: a valid read of this register fires after the malformed frame.
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(probe).publish_state(1);
return 1;
sensor:
- platform: template
name: "write_seen"
id: write_seen
- platform: template
name: "probe"
id: probe
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -0,0 +1,147 @@
esphome:
name: uart-mock-modbus-shared
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_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
responses:
# Three sensors, one frame. At 0x9001 a U_WORD (1 register) and a U_DWORD (2 registers) share the
# start address but cannot merge, so the range widens to count 2. A third sensor at 0x9002 falls
# inside the widened range and must read its slice of the same response rather than splitting into
# a second overlapping poll. The single expect_tx pins the "one frame on the wire" contract - any
# duplicate or overlapping range would put an extra frame on the bus and fail to match.
- expect_tx: [0x01, 0x03, 0x90, 0x01, 0x00, 0x02, 0xB8, 0xCB] # Read holding 0x9001 count 2 on device 1
inject_rx: [0x01, 0x03, 0x04, 0x03, 0x97, 0x02, 0x91, 0x8B, 0x57] # 0x9001=0x0397, 0x9002=0x0291
# A sensor at 0x30 with the deprecated force_new_range (migrates to reuse_previous_range: false)
# and a plain sensor at 0x10. The two must poll as separate ranges: the 0x10 sensor must not be
# absorbed into the isolated 0x30 range.
- expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # Read holding 0x30 count 1 (forced range)
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x11, 0x79, 0xD8] # 0x30 = 0x0111 = 273
- expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # Read holding 0x10 count 1 (own range)
inject_rx: [0x01, 0x03, 0x02, 0x02, 0x22, 0x39, 0x3D] # 0x10 = 0x0222 = 546
# A wide sensor (U_QWORD at 0x100, 4 registers) followed by plain sensors at 0x101 and 0x103.
# None of them merge, so all three poll separately - exactly as before the range refactor. The
# 0x103 sensor sits at the wide range's tail address, so it must not anchor a re-use join on a
# mid-range predecessor and inherit its byte offset.
- expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x04, 0x45, 0xF5] # Read holding 0x100 count 4
inject_rx: [0x01, 0x03, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x94, 0x3C] # = 100
- expect_tx: [0x01, 0x03, 0x01, 0x01, 0x00, 0x01, 0xD4, 0x36] # Read holding 0x101 count 1
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x41, 0x79, 0xE4] # 0x101 = 0x0141 = 321
- expect_tx: [0x01, 0x03, 0x01, 0x03, 0x00, 0x01, 0x75, 0xF6] # Read holding 0x103 count 1
inject_rx: [0x01, 0x03, 0x02, 0x01, 0xA5, 0x79, 0xAF] # 0x103 = 0x01A5 = 421
# Two sensors sharing start address 0x200 (a word and a dword) widen the range to 2 registers
# and both decode from the single response.
- expect_tx: [0x01, 0x03, 0x02, 0x00, 0x00, 0x02, 0xC5, 0xB3] # Read holding 0x200 count 2
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x41, 0x00, 0x02, 0x2A, 0x1A] # 0x200=0x0141, 0x201=0x0002
modbus:
uart_id: virtual_uart_dev
send_wait_time: 200ms
turnaround_time: 10ms
modbus_controller:
- address: 1
id: modbus_controller_ok
max_cmd_retries: 2
update_interval: never
sensor:
# Word sensor at 0x9001 (1 register)
- platform: modbus_controller
name: "shared_word"
address: 0x9001
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# Dword sensor at the SAME address 0x9001 (2 registers) - non-mergeable, shares the range start
- platform: modbus_controller
name: "shared_dword"
address: 0x9001
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
# Word sensor at 0x9002 - inside the widened range, reads bytes 2-3 of the same response
- platform: modbus_controller
name: "covered_word"
address: 0x9002
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# Isolated sensor (deprecated spelling, migrates to reuse_previous_range: false): own range
- platform: modbus_controller
name: "forced_high"
address: 0x30
register_type: holding
value_type: U_WORD
force_new_range: true
modbus_controller_id: modbus_controller_ok
# Plain sensor at a lower address: must get its own range, never absorbed into the isolated one
- platform: modbus_controller
name: "plain_low"
address: 0x10
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# Wide sensor spanning 0x100-0x103; the two sensors below sit inside its span but do not merge
- platform: modbus_controller
name: "wide_qword"
address: 0x100
register_type: holding
value_type: U_QWORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "inside_wide"
address: 0x101
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# At the wide range's tail address: must decode its own poll, not inherit a mid-range byte offset
- platform: modbus_controller
name: "tail_of_wide"
address: 0x103
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# Shared address 0x200: the dword widens the range the word opened (or vice versa)
- platform: modbus_controller
name: "widen_word"
address: 0x200
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "widen_dword"
address: 0x200
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(modbus_controller_ok).set_update_interval(1000);
id(modbus_controller_ok).start_poller();
@@ -0,0 +1,16 @@
esphome:
name: wh-template-unknown-test
host:
api:
logger:
water_heater:
- platform: template
id: unknown_boiler
name: Unknown Boiler
# Both temperatures stay unknown, as they do before an upstream component reports a value.
current_temperature: !lambda "return NAN;"
target_temperature: !lambda "return NAN;"
supported_modes:
- "off"
- eco
+28 -10
View File
@@ -1,7 +1,7 @@
"""Helpers for manipulating the host platform's preferences file.
ESPHome's host platform stores preferences in
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
mirrors ``HostPreferences::sync()``:
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
@@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
from __future__ import annotations
import os
from pathlib import Path
import struct
def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device."""
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
"""Return the on-disk prefs file path for a host-platform device.
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
data if the fixture is ever bypassed."""
prefdir = os.environ.get("ESPHOME_PREFDIR")
if not prefdir:
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
return Path(prefdir) / f"{device_name}.prefs"
def clear_host_prefs(device_name: str) -> None:
@@ -25,15 +33,25 @@ def clear_host_prefs(device_name: str) -> None:
host_prefs_path(device_name).unlink(missing_ok=True)
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
"""Write preference entries, replacing the file's contents.
Returns the path that was written.
"""
payload = b""
for key, data in entries.items():
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
payload += struct.pack("<IB", key, len(data)) + data
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return path
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
Returns the path that was written.
"""
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
payload = struct.pack("<IB", key, len(data)) + data
path.write_bytes(payload)
return path
return write_host_prefs(device_name, {key: data})
@@ -0,0 +1,155 @@
{
"tests/integration/test_action_concurrent_reentry.py": 34.72,
"tests/integration/test_addressable_light_transition.py": 33.71,
"tests/integration/test_alarm_control_panel_state_transitions.py": 38.96,
"tests/integration/test_api_action_metadata.py": 33.36,
"tests/integration/test_api_action_responses.py": 26.22,
"tests/integration/test_api_action_timeout.py": 25.41,
"tests/integration/test_api_conditional_memory.py": 20.74,
"tests/integration/test_api_custom_services.py": 23.21,
"tests/integration/test_api_get_time_response_timezone.py": 25.04,
"tests/integration/test_api_homeassistant.py": 24.18,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86,
"tests/integration/test_api_list_entities_backpressure.py": 18.8,
"tests/integration/test_api_message_size_batching.py": 28.8,
"tests/integration/test_api_reboot_timeout.py": 9.47,
"tests/integration/test_api_string_lambda.py": 16.88,
"tests/integration/test_api_vv_logging.py": 17.99,
"tests/integration/test_api_zero_psk_provisioning.py": 47.07,
"tests/integration/test_areas_and_devices.py": 20.77,
"tests/integration/test_automation_wait_actions.py": 20.07,
"tests/integration/test_automations.py": 27.41,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54,
"tests/integration/test_binary_sensor_invalidate_state.py": 16.09,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7,
"tests/integration/test_build_info.py": 18.07,
"tests/integration/test_camera_mock.py": 20.44,
"tests/integration/test_climate_control_action.py": 27.97,
"tests/integration/test_climate_custom_modes.py": 26.77,
"tests/integration/test_continuation_actions.py": 12.09,
"tests/integration/test_cover_control_action.py": 19.77,
"tests/integration/test_crc8_helper.py": 12.64,
"tests/integration/test_device_id_in_state.py": 63.19,
"tests/integration/test_duplicate_entities.py": 29.26,
"tests/integration/test_entity_icon.py": 34.95,
"tests/integration/test_fan_turn_on_action.py": 25.98,
"tests/integration/test_fnv1_hash_object_id.py": 4.85,
"tests/integration/test_fnv1a_hash.py": 5.14,
"tests/integration/test_gpio_expander_cache.py": 21.0,
"tests/integration/test_host_logger_thread_safety.py": 17.51,
"tests/integration/test_host_mode_basic.py": 21.2,
"tests/integration/test_host_mode_batch_delay.py": 26.68,
"tests/integration/test_host_mode_climate_basic_state.py": 18.04,
"tests/integration/test_host_mode_climate_control.py": 29.64,
"tests/integration/test_host_mode_empty_string_options.py": 28.8,
"tests/integration/test_host_mode_entity_fields.py": 28.68,
"tests/integration/test_host_mode_fan_preset.py": 16.98,
"tests/integration/test_host_mode_many_entities.py": 39.8,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12,
"tests/integration/test_host_mode_noise_encryption.py": 52.53,
"tests/integration/test_host_mode_reconnect.py": 14.48,
"tests/integration/test_host_mode_sensor.py": 17.38,
"tests/integration/test_host_ota.py": 94.96,
"tests/integration/test_host_preferences.py": 27.11,
"tests/integration/test_host_preferences_suspend_resume.py": 21.48,
"tests/integration/test_improv_serial_uart.py": 19.43,
"tests/integration/test_large_message_batching.py": 25.67,
"tests/integration/test_legacy_area.py": 15.59,
"tests/integration/test_legacy_climate_compat.py": 18.59,
"tests/integration/test_legacy_fan_compat.py": 18.34,
"tests/integration/test_light_automations.py": 16.74,
"tests/integration/test_light_binary_effect_off_phase.py": 57.7,
"tests/integration/test_light_calls.py": 25.88,
"tests/integration/test_light_constant_brightness.py": 22.32,
"tests/integration/test_light_control_action.py": 18.15,
"tests/integration/test_light_dim_relative_action.py": 30.35,
"tests/integration/test_light_effect_zero_brightness.py": 18.38,
"tests/integration/test_light_initial_state.py": 23.93,
"tests/integration/test_light_toggle_action.py": 26.16,
"tests/integration/test_lock_automations.py": 34.13,
"tests/integration/test_logger_buffered_recursion_guard.py": 25.77,
"tests/integration/test_loop_disable_enable.py": 14.71,
"tests/integration/test_loop_interval_decoupling.py": 26.16,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47,
"tests/integration/test_lvgl_headless_render.py": 96.36,
"tests/integration/test_micros_to_millis.py": 28.76,
"tests/integration/test_multi_click_trigger.py": 19.8,
"tests/integration/test_multi_device_preferences.py": 38.85,
"tests/integration/test_noise_encryption_key_protection.py": 25.81,
"tests/integration/test_object_id_api_verification.py": 28.46,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14,
"tests/integration/test_object_id_no_friendly_name.py": 18.82,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67,
"tests/integration/test_online_image_bmp.py": 7.41,
"tests/integration/test_oversized_payloads.py": 59.52,
"tests/integration/test_preference_key_stability.py": 27.24,
"tests/integration/test_runtime_stats.py": 20.53,
"tests/integration/test_safe_mode_loop_runs.py": 10.17,
"tests/integration/test_scheduler_blocking_warning.py": 51.45,
"tests/integration/test_scheduler_bulk_cleanup.py": 22.59,
"tests/integration/test_scheduler_defer_cancel.py": 17.64,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.61,
"tests/integration/test_scheduler_defer_fifo_simple.py": 24.73,
"tests/integration/test_scheduler_defer_stress.py": 23.91,
"tests/integration/test_scheduler_heap_stress.py": 25.77,
"tests/integration/test_scheduler_internal_id_no_collision.py": 19.83,
"tests/integration/test_scheduler_interval_reschedule.py": 23.4,
"tests/integration/test_scheduler_interval_zero_coerced.py": 5.11,
"tests/integration/test_scheduler_null_name.py": 17.36,
"tests/integration/test_scheduler_numeric_id_test.py": 20.81,
"tests/integration/test_scheduler_pool.py": 17.42,
"tests/integration/test_scheduler_rapid_cancellation.py": 25.28,
"tests/integration/test_scheduler_recursive_timeout.py": 16.48,
"tests/integration/test_scheduler_removed_item_race.py": 16.14,
"tests/integration/test_scheduler_self_keyed.py": 26.19,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26,
"tests/integration/test_scheduler_string_test.py": 27.09,
"tests/integration/test_script_array_params.py": 3.6,
"tests/integration/test_script_delay_params.py": 24.74,
"tests/integration/test_script_queued.py": 17.21,
"tests/integration/test_script_queued_idle_loop.py": 3.4,
"tests/integration/test_script_wait_on_boot.py": 23.7,
"tests/integration/test_sdl_headless_screenshot.py": 19.53,
"tests/integration/test_select_stringref_trigger.py": 18.93,
"tests/integration/test_sensor_filters_delta.py": 20.85,
"tests/integration/test_sensor_filters_ring_buffer.py": 16.82,
"tests/integration/test_sensor_filters_sliding_window.py": 54.78,
"tests/integration/test_sensor_filters_value_list.py": 19.46,
"tests/integration/test_sensor_timeout_filter.py": 18.39,
"tests/integration/test_set_internal_at_boot.py": 21.69,
"tests/integration/test_snapshot_display.py": 12.64,
"tests/integration/test_socket_wake_gate_tcp.py": 13.08,
"tests/integration/test_status_flags.py": 29.54,
"tests/integration/test_strftime_to.py": 25.62,
"tests/integration/test_syslog.py": 16.25,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18,
"tests/integration/test_template_climate_basic.py": 20.51,
"tests/integration/test_template_climate_custom_modes.py": 27.73,
"tests/integration/test_template_climate_nonoptimistic.py": 26.35,
"tests/integration/test_template_climate_on_control_ordering.py": 26.55,
"tests/integration/test_template_climate_publish_all_fields.py": 17.59,
"tests/integration/test_template_climate_sensor_push.py": 22.04,
"tests/integration/test_template_climate_set_actions.py": 16.82,
"tests/integration/test_template_climate_two_point_temperature.py": 25.13,
"tests/integration/test_template_text_save.py": 25.36,
"tests/integration/test_text_command.py": 18.79,
"tests/integration/test_text_sensor_raw_state.py": 17.07,
"tests/integration/test_uart_mock_ld2410.py": 59.58,
"tests/integration/test_uart_mock_ld2412.py": 59.4,
"tests/integration/test_uart_mock_ld2420.py": 45.27,
"tests/integration/test_uart_mock_ld2450.py": 27.96,
"tests/integration/test_uart_mock_modbus.py": 562.45,
"tests/integration/test_udp.py": 7.48,
"tests/integration/test_use_address_runtime.py": 17.27,
"tests/integration/test_valve_control_action.py": 18.33,
"tests/integration/test_varint_five_byte_device_id.py": 17.59,
"tests/integration/test_wait_until_mid_loop_timing.py": 16.93,
"tests/integration/test_wait_until_on_boot.py": 19.96,
"tests/integration/test_wait_until_ordering.py": 16.19,
"tests/integration/test_wait_until_reentrant_restart.py": 23.67,
"tests/integration/test_wake_loop_forces_phase_b.py": 17.58,
"tests/integration/test_water_heater_template.py": 21.96
}
+48
View File
@@ -0,0 +1,48 @@
"""Helpers for asserting on log output in integration tests."""
from __future__ import annotations
import asyncio
class LineWaiter:
"""Collects log lines and lets a test await one containing all needles.
Pass ``callback`` as ``run_compiled``'s ``line_callback``; the callback runs
on the test's own event loop, so futures are resolved directly. Only one
``wait_for`` may be outstanding at a time (tests await sequentially).
"""
def __init__(self) -> None:
self.lines: list[str] = []
self._needles: tuple[str, ...] = ()
self._future: asyncio.Future | None = None
def callback(self, line: str) -> None:
self.lines.append(line)
if (
self._future is not None
and not self._future.done()
and all(n in line for n in self._needles)
):
self._future.set_result(line)
self._future = None
async def wait_for_each(self, *texts: str, timeout: float = 10.0) -> None:
"""Await each text in turn; a text may match a line already received."""
for text in texts:
await self.wait_for(text, timeout=timeout)
async def wait_for(self, *needles: str, timeout: float = 10.0) -> str:
"""Return the first line, past or future, containing every needle."""
for line in self.lines:
if all(n in line for n in needles):
return line
assert self._future is None or self._future.done(), "concurrent wait_for"
self._needles = needles
self._future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._future, timeout)
finally:
self._future = None
self._needles = ()
+158
View File
@@ -0,0 +1,158 @@
"""Shared fixture server and log helpers for the online_image integration tests."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import re
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
async def wait_for_download(
downloaded_bytes_future: asyncio.Future,
server_error_future: asyncio.Future,
) -> int:
"""Await the downloaded byte count, raising a server handler error first."""
await asyncio.wait(
{downloaded_bytes_future, server_error_future},
return_when=asyncio.FIRST_COMPLETED,
)
if server_error_future.done() and (exc := server_error_future.exception()):
raise exc
# Retrieve a late teardown error so asyncio does not log it at GC
server_error_future.add_done_callback(lambda f: f.exception())
return downloaded_bytes_future.result()
def make_download_watcher(
downloaded_bytes_future: asyncio.Future,
download_finished_future: asyncio.Future,
) -> Callable[[str], None]:
"""Build a line callback resolving the futures from the device log."""
def check_output(line: str) -> None:
if (
match := re.search(r"Image fully downloaded, (\d+) bytes", line)
) and not downloaded_bytes_future.done():
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line and not download_finished_future.done():
download_finished_future.set_result(True)
return check_output
def handle_http(
http_request_future,
content_type: str = "text/plain",
*,
request_path: str = "/foo.bmp",
request_line_consumed: bool = False,
server_error_future: asyncio.Future | None = None,
):
async def handler(reader, writer):
try:
# Only read the request line if it hasn't been consumed by a caller
if not request_line_consumed:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
expected_request = f"GET {request_path} HTTP/1.1\r\n".encode()
assert data[: len(expected_request)] == expected_request
async with asyncio.timeout(1.0):
await reader.readuntil(b"\r\n\r\n")
if not http_request_future.done():
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
f"Content-Type: {content_type}".encode(),
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if server_error_future is not None and not server_error_future.done():
server_error_future.set_exception(exc)
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
def handle_http_redirect(
http_request_future, final_request_future, server_error_future, port_holder
):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
request = await reader.readuntil(b"\r\n")
if (
request[: len(b"GET /foo.bmp HTTP/1.1\r\n")]
== b"GET /foo.bmp HTTP/1.1\r\n"
):
if not http_request_future.done():
http_request_future.set_result(True)
async with asyncio.timeout(1.0):
await reader.readuntil(b"\r\n\r\n")
http_response = [
b"HTTP/1.1 302 Found",
f"Location: http://127.0.0.1:{port_holder['port']}/final.bmp".encode(),
b"Content-Type: text/html",
b"Content-Length: 0",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
return
assert (
request[: len(b"GET /final.bmp HTTP/1.1\r\n")]
== b"GET /final.bmp HTTP/1.1\r\n"
)
if not final_request_future.done():
final_request_future.set_result(True)
await handle_http(
final_request_future,
"image/bmp",
request_path="/final.bmp",
request_line_consumed=True,
server_error_future=server_error_future,
)(reader, writer)
except Exception as exc:
# Route handler failures to the dedicated error future so they're not silently lost
if not server_error_future.done():
server_error_future.set_exception(exc)
if not http_request_future.done():
http_request_future.set_exception(exc)
if not final_request_future.done():
final_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
+148
View File
@@ -0,0 +1,148 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; payloads are skipped and only message types are counted.
"""
from __future__ import annotations
import asyncio
from collections import Counter
import socket
from typing import Self
from aioesphomeapi import api_pb2
import aioesphomeapi.core as api_core
from google.protobuf import message
from .const import LOCALHOST
# Message type ids are protocol constants; derive them from aioesphomeapi so
# they cannot drift from the client library in use.
MESSAGE_TYPE_OF = {cls: num for num, cls in api_core.MESSAGE_TYPE_TO_PROTO.items()}
_READ_CHUNK = 4096
def encode_varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if value:
out.append(byte | 0x80)
else:
out.append(byte)
return bytes(out)
def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None:
"""Decode one varint at pos; return (value, new_pos) or None if short."""
value = shift = 0
while pos < len(buf):
byte = buf[pos]
pos += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, pos
shift += 7
return None
def encode_frame(msg_type: int, payload: bytes) -> bytes:
"""Encode one plaintext api frame: 0x00, payload length, message type."""
return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload
class FrameParser:
"""Incremental parser for the plaintext api frame stream."""
def __init__(self) -> None:
self._buf = bytearray()
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
return types
def _try_parse(self) -> int | None:
buf = self._buf
if not buf:
return None
assert buf[0] == 0, f"expected plaintext frame, got indicator {buf[0]}"
if (size_decoded := decode_varint(buf, 1)) is None:
return None
size, pos = size_decoded
if (type_decoded := decode_varint(buf, pos)) is None:
return None
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
del buf[: pos + size]
return msg_type
class RawApiClient:
"""Plaintext api client whose reads happen only on request."""
def __init__(self, port: int, recv_buffer_size: int | None = None) -> None:
self._port = port
self._parser = FrameParser()
self.bytes_received = 0
self.frame_counts: Counter[int] = Counter()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
if recv_buffer_size is not None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, recv_buffer_size)
# Kernels may round up (Linux doubles) but must not clamp below
applied = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
assert applied >= recv_buffer_size, (
f"SO_RCVBUF clamped to {applied}, requested {recv_buffer_size}"
)
sock.setblocking(False)
except Exception:
sock.close()
raise
self._sock = sock
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *exc_info: object) -> None:
self.close()
async def connect(self, client_info: str = "raw-api-client") -> None:
"""Connect and complete the Hello handshake (no auth step since 2026.1.0)."""
loop = asyncio.get_running_loop()
await loop.sock_connect(self._sock, (LOCALHOST, self._port))
hello = api_pb2.HelloRequest()
hello.client_info = client_info
hello.api_version_major = 1
hello.api_version_minor = 10
await self.send_message(hello)
await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse])
async def send_message(self, msg: message.Message) -> None:
loop = asyncio.get_running_loop()
await loop.sock_sendall(
self._sock,
encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()),
)
async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None:
"""Read until at least one frame of msg_type has been received."""
loop = asyncio.get_running_loop()
async def _read_loop() -> None:
while not self.frame_counts[msg_type]:
data = await loop.sock_recv(self._sock, _READ_CHUNK)
assert data, "server closed the connection unexpectedly"
self.bytes_received += len(data)
self.frame_counts.update(self._parser.feed(data))
await asyncio.wait_for(_read_loop(), timeout)
def close(self) -> None:
self._sock.close()
+40 -10
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::
@@ -419,20 +420,32 @@ class SensorTracker:
"""Call ``expect`` for every entry and return a dict of futures."""
return {name: self.expect(name, value) for name, value in expected.items()}
def on_state(self, state: EntityState) -> None:
"""State callback suitable for ``subscribe_states``."""
if not isinstance(state, SensorState) or state.missing_state:
def on_state(self, state: EntityState, first_pending_only: bool = False) -> None:
"""State callback suitable for ``subscribe_states``.
Args:
state: The state update to record
first_pending_only: Only allow the first pending expectation for this
sensor to match, instead of the first matching one. Used for
connect-time states so they cannot satisfy a later phase.
"""
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:
return
self.sensor_states[sensor_name].append(state.state)
for expected_value, future in self._expectations.get(sensor_name, []):
if not future.done() and (
expected_value is self._ANY or state.state == expected_value
):
if future.done():
continue
if expected_value is self._ANY or state.state == expected_value:
future.set_result(True)
break
if first_pending_only:
break
async def await_change(
self, future: asyncio.Future, name: str, timeout: float = 2.0
@@ -470,8 +483,22 @@ class SensorTracker:
for name, future in futures.items():
await self.await_change(future, name, timeout=timeout)
async def setup_and_start_scenario(self, client) -> list:
"""Wire up subscriptions, wait for initial states, press Start Scenario."""
async def setup_and_start_scenario(
self, client: APIClient, match_initial_states: bool = False
) -> list[EntityInfo]:
"""Wire up subscriptions, wait for initial states, press Start Scenario.
Args:
client: The connected API client
match_initial_states: Also match expectations against the states the
device sends when the client connects, so a value published before
the client subscribed still counts. Binary sensors need this: they
drop repeats, so a value that lands in the connect-time dump is
never sent again. Plain sensors publish on every poll, so there it
only saves waiting for the next one. Only the first pending
expectation per sensor can match, so a connect-time value cannot
satisfy a later phase.
"""
entities, _ = await client.list_entities_services()
self.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(self.sensor_states.keys()))
@@ -484,6 +511,9 @@ class SensorTracker:
import pytest
pytest.fail("Timeout waiting for initial states")
if match_initial_states:
for state in initial_state_helper.initial_states.values():
self.on_state(state, first_pending_only=True)
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
@@ -0,0 +1,65 @@
"""Integration test for user-defined action field metadata."""
from __future__ import annotations
import asyncio
import re
import pytest
from esphome.helpers import fnv1_hash
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_api_action_metadata(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Action and argument metadata reach the client and the actions still run."""
loop = asyncio.get_running_loop()
buzzer_called = loop.create_future()
plain_called = loop.create_future()
buzzer_pattern = re.compile(r"Buzzer: two_short")
plain_pattern = re.compile(r"Plain action called")
def check_output(line: str) -> None:
if not buzzer_called.done() and buzzer_pattern.search(line):
buzzer_called.set_result(True)
elif not plain_called.done() and plain_pattern.search(line):
plain_called.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
by_name = {service.name: service for service in services}
assert set(by_name) == {"play_buzzer", "plain_action"}
# Keys are hashed at codegen time and must match what the client expects
for name, service in by_name.items():
assert service.key == fnv1_hash(name), name
buzzer = by_name["play_buzzer"]
assert buzzer.description == "Play an RTTTL melody on the buzzer"
args = {arg.name: arg for arg in buzzer.args}
assert args["song_str"].description == "RTTTL melody string"
assert args["song_str"].example == "two_short:d=4,o=5,b=100:16e6,16e6"
# An arg without metadata sends empty strings
assert args["volume"].description == ""
assert args["volume"].example == ""
# An action without metadata sends empty strings
plain = by_name["plain_action"]
assert plain.description == ""
assert plain.args[0].description == ""
await client.execute_service(
buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3}
)
await client.execute_service(plain, {"value": 1})
await asyncio.wait_for(buzzer_called, timeout=5.0)
await asyncio.wait_for(plain_called, timeout=5.0)
@@ -0,0 +1,67 @@
"""Integration test for GetTimeResponse parsed_timezone presence handling."""
from __future__ import annotations
from aioesphomeapi import connection as api_connection
from aioesphomeapi.api_pb2 import GetTimeResponse
import pytest
from .state_utils import SensorTracker, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
# 2024-01-01 00:00:00 UTC
EPOCH = 1704067200
# POSIX offsets are positive west of UTC, so UTC+7 is -25200 and UTC-5 is 18000
UTC_PLUS_7 = -25200
UTC_MINUS_5 = 18000
@pytest.mark.asyncio
async def test_api_get_time_response_timezone(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A present parsed_timezone is applied even when all zero; an absent one is ignored."""
# The client answers the device's own GetTimeRequest with the host timezone;
# strip the parsed field from that reply so only the messages sent below
# can change the device timezone.
monkeypatch.setattr(api_connection, "_build_parsed_tz_proto", lambda tz: None)
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
tracker = SensorTracker(["tz_offset"])
tracker.key_to_sensor = build_key_to_entity_mapping(entities, ["tz_offset"])
client.subscribe_states(tracker.on_state)
await tracker.await_change(tracker.expect_any("tz_offset"), "tz_offset")
initial = tracker.sensor_states["tz_offset"][-1]
# Pick a zone that differs from the codegen default so the change is visible
target = UTC_PLUS_7 if initial != UTC_PLUS_7 else UTC_MINUS_5
# Present, non-zero: applied
future = tracker.expect("tz_offset", target)
resp = GetTimeResponse(epoch_seconds=EPOCH)
resp.parsed_timezone.std_offset_seconds = target
resp.parsed_timezone.dst_offset_seconds = target
client._connection.send_messages((resp,))
await tracker.await_change(future, "tz_offset")
# Absent (legacy client with only the deprecated string): ignored, and in
# particular not mistaken for an all-zero UTC zone
future = tracker.expect("tz_offset", 0)
resp = GetTimeResponse(epoch_seconds=EPOCH, timezone="UTC0")
client._connection.send_messages((resp,))
await tracker.await_must_not_change(future, "tz_offset", timeout=1.0)
assert tracker.sensor_states["tz_offset"][-1] == target
# Retire the expectation so it cannot swallow the first matching state
# meant for the next phase
future.cancel()
# Present but all zero (genuine UTC): applied
future = tracker.expect("tz_offset", 0)
resp = GetTimeResponse(epoch_seconds=EPOCH)
resp.parsed_timezone.SetInParent()
client._connection.send_messages((resp,))
await tracker.await_change(future, "tz_offset")
@@ -0,0 +1,98 @@
"""Test on_press/on_release for homeassistant binary sensors on the first HA state."""
from __future__ import annotations
import asyncio
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
ENTITIES = (
"binary_sensor.initial_on",
"binary_sensor.default",
"binary_sensor.unavailable_first",
"binary_sensor.initial_off",
"binary_sensor.default_unavail",
)
@pytest.mark.asyncio
async def test_api_homeassistant_binary_sensor_initial_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The first state from HA fires on_press only with trigger_on_initial_state."""
loop = asyncio.get_running_loop()
waiter = LineWaiter()
subscribed: set[str] = set()
all_subscribed = loop.create_future()
def on_state_sub(entity_id: str, _attribute: str | None) -> None:
subscribed.add(entity_id)
if not all_subscribed.done() and subscribed.issuperset(ENTITIES):
all_subscribed.set_result(None)
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
client.subscribe_home_assistant_states(on_state_sub)
try:
await asyncio.wait_for(all_subscribed, timeout=5.0)
except TimeoutError:
pytest.fail(f"never subscribed: {set(ENTITIES) - subscribed}")
# First state from HA
client.send_home_assistant_state("binary_sensor.initial_on", "", "on")
client.send_home_assistant_state("binary_sensor.default", "", "on")
client.send_home_assistant_state(
"binary_sensor.unavailable_first", "", "unavailable"
)
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "on")
client.send_home_assistant_state(
"binary_sensor.default_unavail", "", "unavailable"
)
client.send_home_assistant_state("binary_sensor.default_unavail", "", "on")
client.send_home_assistant_state("binary_sensor.initial_off", "", "off")
await waiter.wait_for("initial_on on_press", timeout=5.0)
await waiter.wait_for("unavailable_first on_press", timeout=5.0)
# Pin that the 'unavailable' message actually arrived and was rejected
await waiter.wait_for("Can't convert 'unavailable'", timeout=5.0)
# initial_off is the last state sent, so this wait also proves the
# earlier 'default' initial state was already processed
await waiter.wait_for("initial_off on_release", timeout=5.0)
# Both 'unavailable' senders must have been seen and rejected
assert sum("Can't convert 'unavailable'" in line for line in waiter.lines) == 2
# Guard every phase 2 needle against being satisfied by a stale
# phase 1 line, and pin that the initial states fired nothing else
for absent in (
"initial_on on_release",
"default on_press",
"default on_release",
"default_unavail on_press",
"default_unavail on_release",
"unavailable_first on_release",
"initial_off on_press",
):
assert not any(absent in line for line in waiter.lines), (
f"unexpected trigger before the second state change: {absent}"
)
# A later change fires for all of them
client.send_home_assistant_state("binary_sensor.initial_on", "", "off")
client.send_home_assistant_state("binary_sensor.default", "", "off")
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "off")
client.send_home_assistant_state("binary_sensor.initial_off", "", "on")
client.send_home_assistant_state("binary_sensor.default_unavail", "", "off")
await waiter.wait_for_each(
"initial_on on_release",
"default on_release",
"default_unavail on_release",
"unavailable_first on_release",
"initial_off on_press",
timeout=5.0,
)
@@ -0,0 +1,110 @@
"""A client that stops reading the entity listing must not starve other clients.
Service responses are sent directly (not via the deferred batch), so a full
TCP pipe makes the send path refuse; the drive loop now lives in
try_advance(), which stops on refusal instead of retrying forever. Not a
before/after regression test: pre-fix builds survive here because the
refusal path yields and pumps the socket each retry.
The sndbuf_pin_component fixture pins the device's send buffers so the pipe
fills deterministically regardless of kernel autotuning; the test waits for
its log line before proceeding.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .types import APIClientConnectedFactory, RunCompiledFunction
SERVICES_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesServicesResponse]
LIST_DONE_RESPONSE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
# Both ends of the pipe are pinned small; only tens of KB fit in the kernel
RECV_BUFFER_SIZE = 4096
SERVER_SNDBUF = 8192 # substituted into the fixture yaml
# Logged by the sndbuf_pin_component fixture when it pins a socket
SNDBUF_PIN_LOG = "SO_SNDBUF pinned to"
# One response (~6.4 KB) must stay smaller than the pinned send buffer; an
# oversized message parks in the overflow buffer and reports as sent.
ARGS_PER_SERVICE = 8
ARG_NAME_LEN = 800
# ~160 KB listing versus a tens-of-KB pipe guarantees a mid-services block
NUM_SERVICES = 25
assert ARGS_PER_SERVICE * ARG_NAME_LEN < SERVER_SNDBUF
# The pipe fills in well under a second
STALL_SECONDS = 0.5
# Well above pipe capacity, well below the listing size
MIN_DRAINED_BYTES = 60_000
def _generated_actions() -> str:
"""Build the api actions block: services with long argument names."""
lines: list[str] = []
for i in range(NUM_SERVICES):
lines.append(f" - action: backpressure_service_{i:04d}")
lines.append(" variables:")
for j in range(ARGS_PER_SERVICE):
prefix = f"arg_{i:04d}_{j:02d}_"
lines.append(
f" {prefix}{'x' * (ARG_NAME_LEN - len(prefix))}: string"
)
lines.append(" then:")
lines.append(" - logger.log: service called")
return "\n".join(lines)
@pytest.mark.asyncio
async def test_api_list_entities_backpressure(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
"""A stalled reader mid-services must not block other api clients."""
assert "# GENERATED_ACTIONS" in yaml_config
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
pin_applied = asyncio.Event()
def _on_log_line(line: str) -> None:
if SNDBUF_PIN_LOG in line:
pin_applied.set()
async with run_compiled(config, line_callback=_on_log_line):
# Fails loudly if the pin never applied
await asyncio.wait_for(pin_applied.wait(), 10)
async with RawApiClient(
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
) as stalled:
await stalled.connect(client_info="backpressure-stall-client")
await stalled.send_message(api_pb2.ListEntitiesRequest())
# The client now stops reading entirely.
# Let the server run against the full pipe
await asyncio.sleep(STALL_SECONDS)
# Other clients must still be served while the first is blocked
async with api_client_connected(timeout=20) as client:
device_info = await asyncio.wait_for(client.device_info(), 20)
assert device_info.name == "api-backpressure-test"
_, services = await asyncio.wait_for(
client.list_entities_services(), 30
)
assert len(services) == NUM_SERVICES
# Fixture-size guard: the listing must dwarf the pinned pipe
before = stalled.bytes_received
await stalled.read_until_frame(LIST_DONE_RESPONSE, timeout=60)
drained = stalled.bytes_received - before
assert drained > MIN_DRAINED_BYTES, (
f"only {drained} bytes drained; the listing never backed up"
)
assert stalled.frame_counts[SERVICES_RESPONSE] == NUM_SERVICES
assert stalled.frame_counts[LIST_DONE_RESPONSE] == 1
@@ -10,34 +10,39 @@ from __future__ import annotations
import asyncio
import base64
import socket
from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
from .conftest import run_binary_and_wait_for_port
from .const import KEY_ACTIVATION_DELAY, LOCALHOST, PROVISIONING_PSK, ZERO_PSK
from .types import (
APIClientConnectedFactory,
CompileFunction,
ConfigWriter,
RunCompiledFunction,
)
# The well-known provisioning PSK: base64 of 32 zero bytes
ZERO_PSK = base64.b64encode(bytes(32)).decode()
# A real key to provision
NEW_KEY = base64.b64encode(b"n" * 32)
# Time for the device to activate a newly saved key (100ms timer plus margin)
KEY_ACTIVATION_DELAY = 0.5
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so every run starts unprovisioned."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
NEW_KEY = PROVISIONING_PSK
@pytest.mark.asyncio
async def test_api_zero_psk_provisioning(
yaml_config: str,
run_compiled: RunCompiledFunction,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Exercise the reject paths, then provision a key over the zero-PSK channel."""
async with run_compiled(yaml_config):
"""Exercise the reject paths, provision a key over the zero-PSK channel,
and check the key comes back from preferences on the next boot."""
port, port_socket = reserved_tcp_port
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
port_socket.close()
async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port):
# --- Pre-provisioning reject paths (device state is unchanged) ---
# A wrong (non-zero) PSK fails against the zero provisioning PSK
@@ -97,6 +102,19 @@ async def test_api_zero_psk_provisioning(
async with api_client_connected(timeout=5) as client:
await client.device_info()
# The key is loaded from preferences on the next boot
lines: list[str] = []
async with run_binary_and_wait_for_port(
binary_path, LOCALHOST, port, line_callback=lines.append
):
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
device_info = await client.device_info()
assert device_info.api_encryption_provisionable is False
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
await client.device_info()
assert any("Loaded saved Noise PSK" in line for line in lines)
@pytest.mark.asyncio
async def test_api_zero_psk_provisioning_plaintext(
+73
View File
@@ -0,0 +1,73 @@
"""Integration test for the camera API flow using a mock camera platform."""
from __future__ import annotations
import asyncio
from aioesphomeapi import CameraInfo, CameraState, EntityState
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
# Must match image_size in fixtures/camera_mock.yaml
IMAGE_SIZE = 4096
STREAM_FRAMES = 3
def _verify_frame(data: bytes) -> int:
"""Verify the deterministic frame pattern and return the frame counter."""
assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}"
counter = data[0]
assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), (
"frame pattern mismatch"
)
return counter
@pytest.mark.asyncio
async def test_camera_mock(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Single-image and stream requests deliver reassembled deterministic frames."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
camera = require_entity(entities, "mock_camera", CameraInfo)
loop = asyncio.get_running_loop()
images: list[bytes] = []
single_image: asyncio.Future[None] = loop.create_future()
stream_done: asyncio.Future[None] = loop.create_future()
def on_state(state: EntityState) -> None:
if not (isinstance(state, CameraState) and state.key == camera.key):
return
images.append(bytes(state.data))
if not single_image.done():
single_image.set_result(None)
elif len(images) >= STREAM_FRAMES and not stream_done.done():
stream_done.set_result(None)
client.subscribe_states(on_state)
# Single image request: one complete frame arrives, reassembled
# from multiple chunks (4096 > 1390 byte packets)
client.request_single_image()
await asyncio.wait_for(single_image, timeout=10)
first_counter = _verify_frame(images[0])
# Stream request: multiple consecutive frames arrive
images.clear()
client.request_image_stream()
await asyncio.wait_for(stream_done, timeout=10)
# Frames are distinct, ordered, and fresh per the mock's counter.
# Not exactly consecutive: the API drops frames by design while the
# previous image is still being sent, so allow small gaps.
counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]]
for prev, cur in zip(counters, counters[1:], strict=False):
assert cur != prev, f"duplicate frames: {counters}"
assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}"
assert counters[0] != first_counter, "stream should produce new frames"
+272 -72
View File
@@ -8,8 +8,12 @@ instance covers the FD_CLOEXEC path.
from __future__ import annotations
import asyncio
import base64
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
import functools
from pathlib import Path
import socket
import pytest
@@ -17,10 +21,18 @@ import pytest
from esphome import espota2
from .conftest import run_binary, wait_and_connect_api_client
from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT
from .types import CompileFunction, ConfigWriter
from .const import (
KEY_ACTIVATION_DELAY,
LOCALHOST,
PORT_POLL_INTERVAL,
PORT_WAIT_TIMEOUT,
PROVISIONING_PSK,
ZERO_PSK,
)
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
DEVICE_NAME = "host-ota-test"
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@contextmanager
@@ -34,6 +46,14 @@ def _reserve_port() -> Generator[tuple[int, socket.socket]]:
s.close()
async def _wait_for_line(lines: list[str], needle: str, timeout: float = 5.0) -> None:
"""The config dump prints after every setup, a little after the api port
opens, so wait for it rather than assert on the lines seen so far."""
async with asyncio.timeout(timeout):
while not any(needle in line for line in lines):
await asyncio.sleep(PORT_POLL_INTERVAL)
async def _wait_for_port(host: str, port: int, timeout: float) -> None:
"""Poll until a TCP port accepts connections, or raise TimeoutError."""
loop = asyncio.get_running_loop()
@@ -50,6 +70,102 @@ async def _wait_for_port(host: str, port: int, timeout: float) -> None:
raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s")
async def _build(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> tuple[int, int, Path]:
"""Reserve an OTA port, compile the fixture with it, and release both
ports right before the binary is started."""
api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket):
config_path = await write_yaml_config(
yaml_config.replace("__OTA_PORT__", str(ota_port))
)
binary_path = await compile_esphome(config_path)
api_socket.close()
ota_socket.close()
return api_port, ota_port, binary_path
async def _run_ota(
ota_port: int,
password: str | None,
binary_path: Path,
noise_psk: str | None,
plaintext_fallback: bool = False,
) -> int:
"""espota2 is blocking; run it in the executor and return its exit code."""
rc, _ = await asyncio.get_running_loop().run_in_executor(
None,
functools.partial(
espota2.run_ota,
LOCALHOST,
ota_port,
password,
binary_path,
noise_psk=noise_psk,
plaintext_fallback=plaintext_fallback,
),
)
return rc
@dataclass
class _Device:
"""A running host binary and the checks every successful OTA repeats:
a safe reboot, the api port back up, and the pid preserved by execv."""
api_port: int
ota_port: int
binary_path: Path
proc: asyncio.subprocess.Process | None = None
reboots: int = 0
def __post_init__(self) -> None:
self._rebooted = asyncio.Event()
def on_log(self, line: str) -> None:
if "Rebooting safely" in line:
self.reboots += 1
self._rebooted.set()
async def wait_reboot(self, count: int, timeout: float = 10.0) -> None:
async with asyncio.timeout(timeout):
while self.reboots < count:
self._rebooted.clear()
await self._rebooted.wait()
async def ota(
self,
password: str | None,
noise_psk: str | None,
msg: str,
plaintext_fallback: bool = False,
) -> None:
"""Upload, then expect the re-exec with the pid preserved."""
pid_before = self.proc.pid
expected_reboots = self.reboots + 1
rc = await _run_ota(
self.ota_port, password, self.binary_path, noise_psk, plaintext_fallback
)
assert rc == 0, msg
await self.wait_reboot(expected_reboots)
await _wait_for_port(LOCALHOST, self.api_port, PORT_WAIT_TIMEOUT)
assert self.proc.returncode is None, "process exited instead of execing"
assert self.proc.pid == pid_before
async def refused_ota(
self, password: str | None, noise_psk: str | None, msg: str
) -> None:
"""Upload must fail and the device must keep running."""
rc = await _run_ota(self.ota_port, password, self.binary_path, noise_psk)
assert rc == 1, msg
await asyncio.sleep(0.5)
assert self.proc.returncode is None, "process died on rejected OTA"
@pytest.mark.asyncio
async def test_host_ota_self_update(
yaml_config: str,
@@ -58,57 +174,149 @@ async def test_host_ota_self_update(
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Self-OTA: upload the running binary back to itself, expect re-exec."""
api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket):
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
api_socket.close()
ota_socket.close()
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
staged = asyncio.Event()
loop = asyncio.get_running_loop()
ota_staged = loop.create_future()
rebooted = loop.create_future()
def on_log(line: str) -> None:
if "OTA staged at" in line:
staged.set()
dev.on_log(line)
def on_log(line: str) -> None:
if not ota_staged.done() and "OTA staged at" in line:
ota_staged.set_result(True)
if not rebooted.done() and "Rebooting safely" in line:
rebooted.set_result(True)
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
async with wait_and_connect_api_client(port=dev.api_port) as client:
info_before = await client.device_info()
assert info_before.name == DEVICE_NAME
async with run_binary(binary_path, line_callback=on_log) as (proc, _lines):
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid
async with wait_and_connect_api_client(port=api_port) as client:
info_before = await client.device_info()
assert info_before.name == DEVICE_NAME
await dev.ota(None, None, "espota2 reported failure")
assert staged.is_set()
# espota2 is blocking; run in executor.
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
async with wait_and_connect_api_client(port=dev.api_port) as client:
info_after = await client.device_info()
assert info_after.name == info_before.name
# Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind).
await dev.ota(None, None, "second OTA failed -- listener leaked across execv")
@pytest.mark.asyncio
async def test_host_ota_encrypted(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Encrypted self-OTA succeeds; a plaintext upload to the same device fails."""
pytest.importorskip("aioesphomeapi.noise")
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await dev.refused_ota(
None, None, "plaintext upload to an encrypted device must fail"
)
await dev.ota(None, API_KEY, "encrypted OTA reported failure")
@pytest.mark.asyncio
async def test_host_ota_api_key_offer_with_password(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
caplog: pytest.LogCaptureFixture,
) -> None:
"""With only an api key the device offers encryption without requiring
it: the password still guards plaintext uploads, the key alone
authenticates an encrypted one, and until 2027.3.0 a failed encrypted
attempt falls back to plaintext."""
pytest.importorskip("aioesphomeapi.noise")
wrong_key = base64.b64encode(b"w" * 32).decode()
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _wait_for_line(lines, "Encryption: offered")
await dev.refused_ota(
None, None, "plaintext upload without the password must fail"
)
await dev.ota(
"hunter2", None, "plaintext upload with the password must succeed"
)
await dev.ota(None, API_KEY, "encrypted upload with the api key must succeed")
# Remove before 2027.3.0: a wrong key falls back to plaintext, which
# the password still guards
with caplog.at_level("WARNING", logger="esphome.espota2"):
await dev.ota(
"hunter2",
wrong_key,
"the plaintext retry with the password must succeed",
plaintext_fallback=True,
)
assert rc == 0, "espota2 reported failure"
assert any("Retrying in plaintext" in r.message for r in caplog.records)
await dev.ota(
None,
API_KEY,
"the right api key encrypts without touching the fallback",
plaintext_fallback=True,
)
await asyncio.wait_for(ota_staged, timeout=10.0)
await asyncio.wait_for(rebooted, timeout=10.0)
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
# execv preserves pid; mismatch means external respawn.
assert proc.returncode is None, "process exited instead of execing"
assert proc.pid == pid_before
@pytest.mark.asyncio
@pytest.mark.usefixtures("isolated_preferences")
async def test_host_ota_provisioned_api_key(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A key provisioned over the api feeds the OTA offer: plaintext works
while unprovisioned, the provisioned key encrypts, the key loaded from
preferences on the next boot keeps encrypting, and plaintext stays
accepted because only the ota block requires encryption."""
pytest.importorskip("aioesphomeapi.noise")
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _wait_for_line(lines, "once the api key is provisioned")
async with wait_and_connect_api_client(port=api_port) as client:
info_after = await client.device_info()
assert info_after.name == DEVICE_NAME
assert info_after.name == info_before.name
await dev.ota(
None, None, "plaintext upload to an unprovisioned device must succeed"
)
# Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind).
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
)
assert rc == 0, "second OTA failed -- listener leaked across execv"
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
assert proc.pid == pid_before
async with api_client_connected(
port=dev.api_port, noise_psk=ZERO_PSK
) as client:
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
await asyncio.sleep(KEY_ACTIVATION_DELAY)
key = PROVISIONING_PSK.decode()
await dev.ota(
None, key, "encrypted upload with the provisioned key must succeed"
)
await dev.ota(None, key, "the key loaded at boot must feed the OTA offer")
await dev.ota(None, None, "plaintext must stay accepted on an offering device")
@pytest.mark.asyncio
@@ -120,33 +328,25 @@ async def test_host_ota_rejects_garbage(
integration_test_dir,
) -> None:
"""Bogus payload is rejected and the device keeps running."""
api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket):
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
# 192 bytes that are neither ELF nor Mach-O.
bogus_path = integration_test_dir / "bogus.bin"
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
# 192 bytes that are neither ELF nor Mach-O.
bogus_path = integration_test_dir / "bogus.bin"
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
async with run_binary(dev.binary_path) as (proc, _lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid
rc = await _run_ota(dev.ota_port, None, bogus_path, None)
assert rc == 1
await asyncio.sleep(0.5)
assert proc.returncode is None, "process died on rejected OTA"
assert proc.pid == pid_before
api_socket.close()
ota_socket.close()
async with run_binary(binary_path) as (proc, _lines):
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid
loop = asyncio.get_running_loop()
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path
)
assert rc == 1
await asyncio.sleep(0.5)
assert proc.returncode is None, "process died on rejected OTA"
assert proc.pid == pid_before
async with wait_and_connect_api_client(port=api_port) as client:
info = await client.device_info()
assert info.name == DEVICE_NAME
async with wait_and_connect_api_client(port=dev.api_port) as client:
info = await client.device_info()
assert info.name == DEVICE_NAME
@@ -0,0 +1,128 @@
"""Test that suspending/resuming the preferences IntervalSyncer actually stops/starts flash writes."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from pathlib import Path
import re
from typing import Any
from aioesphomeapi import ButtonInfo, EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "test_suspend_resume_device"
def find_entity_by_name(
entities: list[EntityInfo], entity_type: type, name: str
) -> Any:
"""Helper to find an entity by type and name."""
return next(
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
)
async def _wait_for(
awaitable: Awaitable[Any], message: str, timeout: float = 5.0
) -> None:
"""Await a future or coroutine, failing the test with a clear message on timeout."""
try:
await asyncio.wait_for(awaitable, timeout=timeout)
except TimeoutError:
pytest.fail(message)
async def _poll_until_exists(path: Path) -> None:
"""Poll for a file to appear, rather than guessing a sleep duration."""
while not path.exists():
await asyncio.sleep(0.05)
@pytest.mark.asyncio
async def test_host_preferences_suspend_resume(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
isolated_preferences: Path,
) -> None:
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
loop = asyncio.get_running_loop()
saved_in_memory = loop.create_future()
syncer_suspended = loop.create_future()
syncer_resumed = loop.create_future()
save_pattern = re.compile(r"Preference saved in memory")
suspend_pattern = re.compile(r"Syncer suspended")
resume_pattern = re.compile(r"Syncer resumed")
def check_output(line: str) -> None:
if save_pattern.search(line) and not saved_in_memory.done():
saved_in_memory.set_result(True)
if suspend_pattern.search(line) and not syncer_suspended.done():
syncer_suspended.set_result(True)
if resume_pattern.search(line) and not syncer_resumed.done():
syncer_resumed.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preference")
suspend_button = find_entity_by_name(entities, ButtonInfo, "Suspend Syncer")
resume_button = find_entity_by_name(entities, ButtonInfo, "Resume Syncer")
assert save_button is not None, "Save Preference button not found"
assert suspend_button is not None, "Suspend Syncer button not found"
assert resume_button is not None, "Resume Syncer button not found"
# --- Positive control: a running syncer flushes to disk. Without this,
# the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). ---
client.button_command(save_button.key)
await _wait_for(
saved_in_memory, "Preference was not saved to memory within timeout"
)
await _wait_for(
_poll_until_exists(pref_file),
"Running syncer never flushed to disk; positive control failed",
timeout=10.0,
)
saved_in_memory = loop.create_future()
# --- Suspend: a suspended syncer must not flush. ---
client.button_command(suspend_button.key)
await _wait_for(
syncer_suspended, "Syncer suspend command was not processed within timeout"
)
# Delete only after suspend is confirmed: the poller is now stopped, so
# nothing can recreate the file before the negative assertion below.
pref_file.unlink()
client.button_command(save_button.key)
await _wait_for(
saved_in_memory, "Preference was not saved to memory within timeout"
)
# Wait well past flash_write_interval (1s): a running syncer would
# have flushed to disk by now, a suspended one must not have. This is a
# negative assertion (proving absence), so a fixed sleep is unavoidable here.
await asyncio.sleep(1.5)
assert not pref_file.exists(), (
"Suspended syncer flushed to disk; component.suspend did not stop the poller"
)
# --- Resume: flushing must restart. ---
client.button_command(resume_button.key)
await _wait_for(
syncer_resumed, "Syncer resume command was not processed within timeout"
)
await _wait_for(
_poll_until_exists(pref_file),
"Resumed syncer never flushed to disk; component.resume did not restart the poller",
timeout=10.0,
)
@@ -0,0 +1,147 @@
"""Integration test for improv_serial over a mocked UART bus.
Drives the improv serial protocol end to end on the host platform:
the fixture wires improv_serial to a uart_mock bus and shadows the wifi
component with a host stub. The test injects improv frames through an API
action and asserts on the framed responses that uart_mock logs as TX lines.
Covered:
1. Get Current State reports AUTHORIZED
2. Get Device Info returns the firmware/device info RPC response
3. Get Wi-Fi Networks returns deduplicated scan results and a terminator
4. Wi-Fi Settings provisions: saves credentials and reports PROVISIONED
"""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
# Improv serial framing (improv_serial_component.h)
IMPROV_HEADER = b"IMPROV"
IMPROV_VERSION = 1
TYPE_CURRENT_STATE = 0x01
TYPE_RPC = 0x03
TYPE_RPC_RESPONSE = 0x04
# improv::Command values
CMD_GET_CURRENT_STATE = 0x02
CMD_GET_DEVICE_INFO = 0x03
CMD_GET_WIFI_NETWORKS = 0x04
CMD_WIFI_SETTINGS = 0x01
def build_rpc_frame(command: int, data: bytes = b"") -> list[int]:
"""Build a full improv serial frame carrying one RPC command."""
payload = bytes([command, len(data)]) + data
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC, len(payload)]) + payload
checksum = sum(frame) & 0xFF
return list(frame + bytes([checksum]) + b"\n")
def state_frame_hex(state: int) -> str:
"""Full 12 byte current-state frame as hex, checksum and newline included."""
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_CURRENT_STATE, 1, state])
checksum = sum(frame) & 0xFF
return ":".join(f"{b:02X}" for b in frame + bytes([checksum]) + b"\n")
def rpc_footer_hex(payload: bytes) -> str:
"""Checksum and newline footer written after an RPC response payload."""
header = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC_RESPONSE, len(payload)])
checksum = (sum(header) + sum(payload)) & 0xFF
return f"{checksum:02X}:0A"
def wifi_settings_data(ssid: str, password: str) -> bytes:
ssid_b = ssid.encode()
pass_b = password.encode()
return bytes([len(ssid_b)]) + ssid_b + bytes([len(pass_b)]) + pass_b
def hex_of(text: str) -> str:
"""Colon separated uppercase hex as logged by format_hex_pretty."""
return ":".join(f"{b:02X}" for b in text.encode())
@pytest.mark.asyncio
async def test_improv_serial_uart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
inject = next(s for s in services if s.name == "uart_inject")
# 1. Get Current State: expect the complete current-state frame reporting
# AUTHORIZED (0x02), checksum and newline included
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_CURRENT_STATE)}
)
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x02)}")
# 2. Get Device Info: the always logged 9 byte response header, then the
# payload with the firmware name (must stay under uart_mock's 64 byte
# hex dump cap or the payload line reads "too large to log")
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_DEVICE_INFO)}
)
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04")
await waiter.wait_for("uart_mock", "TX ", hex_of("ESPHome"))
# 3. Get Wi-Fi Networks: stub scan has TestNet twice (dedup keeps the
# stronger), OpenNet, and a hidden entry (filtered). Expect one response
# per visible network plus the empty terminator.
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_WIFI_NETWORKS)}
)
await waiter.wait_for("uart_mock", hex_of("TestNet"))
await waiter.wait_for("uart_mock", hex_of("OpenNet"))
# Terminator: all three writes of the response frame; 9 byte header,
# payload [0x04, 0x00, 0x00], then the checksum and newline footer
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04:03")
await waiter.wait_for("uart_mock", "TX 3 bytes: 04:00:00")
await waiter.wait_for(
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x04, 0x00, 0x00]))}"
)
testnet_count = sum(
1
for line in waiter.lines
if "uart_mock" in line and "TX " in line and hex_of("TestNet") in line
)
assert testnet_count == 1, (
f"Duplicate scan entry not deduplicated: {testnet_count} TestNet responses"
)
# 4. Wi-Fi Settings: stub connects immediately; expect the credentials
# saved, the PROVISIONED state frame (0x04), and the settings response
await client.execute_service(
inject,
{
"payload": build_rpc_frame(
CMD_WIFI_SETTINGS, wifi_settings_data("NewNet", "secret123")
)
},
)
await waiter.wait_for("save_wifi_sta ssid=NewNet")
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x04)}")
# Settings RPC response carries the formatted next_url and its footer
next_url = b"https://example.com/?device=improv-uart"
payload = (
bytes([CMD_WIFI_SETTINGS, len(next_url) + 1, len(next_url)])
+ next_url
+ b"\x00"
)
await waiter.wait_for(
"uart_mock",
f"TX {len(payload)} bytes: " + ":".join(f"{b:02X}" for b in payload),
)
await waiter.wait_for("uart_mock", f"TX 2 bytes: {rpc_footer_hex(payload)}")
@@ -0,0 +1,207 @@
"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light.
Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect
encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the
effect keeps running instead of being stopped by an explicit turn-off. On a dimmable
light that works, because the output is driven by `state * brightness`. On a binary
light the dark phase used to be dropped, so the output stayed on forever.
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
every frame), so the effect's actual output can't be observed via API state broadcasts.
Instead, this test reads the output component's log lines, which are written on every
update regardless of the publish flag.
The output log line is emitted strictly after the API state response: `perform()`
publishes inline, but the write is deferred to the next `LightState::loop()` iteration
and then has to cross the subprocess stdout pipe. So a future is armed *before* each
command and awaited afterwards, rather than reading the last observed value.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
@pytest.mark.asyncio
async def test_light_binary_effect_off_phase(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A strobe effect must drive a binary light's output both on and off."""
loop = asyncio.get_running_loop()
observed: list[bool] = []
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
observed.append(value)
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
"""Arm a future for the next output write, before sending the command."""
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# ESPHome sends the current state of every entity right after connecting; drain
# that initial burst so it can't be mistaken for the response to a command below.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# A plain turn-on must drive the output on -- brightness defaults to 100% and
# must not be mistaken for a dark phase.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Plain turn-on did not switch the output on"
)
# Run the strobe effect; both phases must reach the output.
observed.clear()
state = await send_and_wait(effect="Fast Strobe")
assert state.effect == "Fast Strobe"
# Let several effect cycles run (each phase is 50ms in the fixture).
await asyncio.sleep(1.0)
assert True in observed, (
f"Strobe effect never switched the output on -- got {observed}"
)
assert False in observed, (
f"Strobe effect never switched the output off; its dark phase was lost -- "
f"got {observed}"
)
# Stopping the effect must leave the light usable.
state = await send_and_wait(effect="None")
assert state.effect == "None"
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light stayed off after the effect stopped"
)
# An explicit turn-off still switches the output off.
output = arm_output()
state = await send_and_wait(state=False)
assert state.state is False
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Turn-off did not switch the output off"
)
@pytest.mark.asyncio
async def test_light_binary_zero_brightness_is_recoverable(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Zero brightness on an ON/OFF light must not leave it permanently stuck off.
An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has
no representable "on but dark" state. It must switch the output off and report the
light as off, and a later plain turn-on must bring it back.
"""
loop = asyncio.get_running_loop()
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True
# Turning on at 0% brightness has no representable "on but dark" state here,
# so the light must switch off and report itself as off.
output = arm_output()
state = await send_and_wait(state=True, brightness=0.0)
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Zero brightness did not switch the output off"
)
assert state.state is False, (
"Light reported itself as on while its output was off"
)
# A plain turn-on must recover -- the stored zero brightness must not persist.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light was left permanently off by a zero-brightness turn-on"
)
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
@@ -0,0 +1,83 @@
"""Integration test that checks what LVGL actually draws, using a display with no screen.
The rendered screen is compared against a hash rather than a checked in reference image, so the
repository does not have to carry a binary file. If a change to the drawing code or to the bundled
LVGL alters the output, this test fails and prints the hash it saw; update EXPECTED_SHA256 once the
new image has been looked at and found to be correct.
The picture is drawn and encoded entirely by code in this repository, so nothing installed on the
machine running the test takes part in the result.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from .artifact_utils import keep_artifact
from .bmp_utils import capture_when_drawn
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 300
HEIGHT = 300
# sha256 of the pixel data of a 300x300 screen showing "Hello World!" centred in white on a dark
# blue background, drawn with the built in montserrat_14 font. To regenerate, run this test and
# take the hash it reports.
EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd48655092ed"
# Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was
# generated against. A version bump can shift anti-aliasing enough to change the hash even though
# nothing is actually wrong -- if this test fails, check that first before regenerating the hash.
EXPECTED_LVGL_VERSION = "9.5.0"
@pytest.mark.asyncio
async def test_lvgl_headless_render(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LVGL draws the expected screen on a 300x300 display with no screen behind it."""
snapshot_dir = tmp_path / "snapshots"
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
service = next(s for s in services if s.name == "take_screenshot")
async def take(name: str) -> None:
await client.execute_service(service, {"name": name})
# The background is not the whole picture: LVGL must have drawn on it. Waiting for that
# rather than for a fixed time keeps a slow first frame from being reported as a hash
# mismatch, which would look like a drawing regression.
image, capture = await capture_when_drawn(take, snapshot_dir, prefix="render")
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
digest = hashlib.sha256(image.pixels).hexdigest()
if digest != EXPECTED_SHA256:
# Kept outside the temporary folder so CI can upload it; see artifact_utils.
kept = keep_artifact(
"lvgl_headless_render_actual.bmp", capture.read_bytes()
)
from esphome.components.lvgl import LVGL_VERSION
version_hint = ""
if LVGL_VERSION != EXPECTED_LVGL_VERSION:
version_hint = (
f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> "
f"{LVGL_VERSION}), which is the likely cause\n"
)
pytest.fail(
f"rendered screen does not match the expected hash\n"
f"{version_hint}"
f" expected: {EXPECTED_SHA256}\n"
f" actual: {digest}\n"
f"the image that was rendered has been kept at {kept}\n"
f"on CI it is in the integration-test-artifacts upload for this job"
)
@@ -0,0 +1,71 @@
"""Test that online_image AUTO format detection reads the Content-Type header."""
from __future__ import annotations
import asyncio
import pytest
from .online_image_utils import (
LEN_BMP_IMAGE,
handle_http,
make_download_watcher,
wait_for_download,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_online_image_auto_detects_image_bmp_mime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""AUTO format detection should honor the final response MIME type without explicit format."""
loop = asyncio.get_running_loop()
http_request_future = loop.create_future()
server_error_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
server = await asyncio.start_server(
handle_http(
http_request_future,
"image/bmp",
server_error_future=server_error_future,
),
"127.0.0.1",
0,
)
http_server_port = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
_, services = await client.list_entities_services()
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
numbytes = await wait_for_download(
downloaded_bytes_future, server_error_future
)
assert numbytes == LEN_BMP_IMAGE
await download_finished_future
@@ -0,0 +1,71 @@
"""Test that AUTO format detection uses the final Content-Type after redirects."""
from __future__ import annotations
import asyncio
import pytest
from .online_image_utils import (
LEN_BMP_IMAGE,
handle_http_redirect,
make_download_watcher,
wait_for_download,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_online_image_auto_detects_redirected_image_bmp_mime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Redirect hops should not leave the 302 HTML Content-Type in place for the final image."""
loop = asyncio.get_running_loop()
http_request_future = loop.create_future()
final_request_future = loop.create_future()
server_error_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
port_holder = {}
server = await asyncio.start_server(
handle_http_redirect(
http_request_future, final_request_future, server_error_future, port_holder
),
"127.0.0.1",
0,
)
port_holder["port"] = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(port_holder["port"]))
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
_, services = await client.list_entities_services()
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
await final_request_future
numbytes = await wait_for_download(
downloaded_bytes_future, server_error_future
)
assert numbytes == LEN_BMP_IMAGE
await download_finished_future
+4 -59
View File
@@ -1,62 +1,12 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .online_image_utils import LEN_BMP_IMAGE, handle_http, make_download_watcher
from .types import APIClientConnectedFactory, RunCompiledFunction
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
def handle_http(http_request_future):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
# ensure our request matches the expectation
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
assert data[: len(expected_request)] == expected_request
# consume rest of request
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n\r\n")
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
b"Content-Type: text/plain",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
@pytest.mark.asyncio
async def test_online_image_bmp(
@@ -72,14 +22,9 @@ async def test_online_image_bmp(
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line:
download_finished_future.set_result(True)
check_output = make_download_watcher(
downloaded_bytes_future, download_finished_future
)
server = await asyncio.start_server(
handle_http(http_request_future), "127.0.0.1", 0
@@ -0,0 +1,168 @@
"""Integration test for entity preference key stability.
Entity preferences are stored under keys derived from the sanitized object_id
hash. This test seeds the host preferences file the way existing firmware
wrote it and verifies the state is restored, proving the key scheme has not
drifted; a save and reload round trip cannot catch drift because it writes
and reads with the same code.
The second run also seeds the raw-name-hash entries a 2026.8 beta device left
behind (see https://github.com/esphome/esphome/pull/18361) and proves they are
ignored: the object_id entries win and the beta leftovers are inert.
"""
from __future__ import annotations
import socket
import struct
from aioesphomeapi import (
NumberInfo,
NumberState,
SwitchInfo,
SwitchState,
TextInfo,
TextState,
)
import pytest
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .host_prefs import clear_host_prefs, write_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-pref-key-stability"
# All entities are on the main device (device_id 0) and their preferences use
# no version salt, so the key is just the object_id hash.
SWITCH_KEY = fnv1_hash_object_id("Test Switch")
NUMBER_KEY = fnv1_hash_object_id("Test Number")
# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build
SWITCH_BETA_KEY = fnv1_hash_name("Test Switch")
NUMBER_BETA_KEY = fnv1_hash_name("Test Number")
# template_text salts its key with the length limits and pattern hash; this must
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
# no pattern configured)
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
TEXT_MAX_LENGTH = 20
def text_pref_payload(value: str) -> bytes:
"""Build the length-prefixed buffer TextSaver stores for a value."""
data = value.encode("utf-8")
assert len(data) <= TEXT_MAX_LENGTH
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
@pytest.mark.asyncio
async def test_preference_key_stability(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test that preferences stored by earlier firmware are restored."""
port, port_socket = reserved_tcp_port
assert SWITCH_KEY != SWITCH_BETA_KEY
assert NUMBER_KEY != NUMBER_BETA_KEY
assert TEXT_KEY != TEXT_BETA_KEY
# Write and compile once
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
# Release the reserved port so the binary can bind to it
port_socket.close()
async def boot_and_get_initial_states() -> tuple[
SwitchState, NumberState, TextState
]:
"""Boot the binary and return the restored entity states."""
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
device_info = await client.device_info()
assert device_info.name == DEVICE_NAME
entities, _ = await client.list_entities_services()
switch_entity = require_entity(
entities, "test_switch", SwitchInfo, "Test Switch"
)
number_entity = require_entity(
entities, "test_number", NumberInfo, "Test Number"
)
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda s: None)
)
await initial_state_helper.wait_for_initial_states()
switch_state = initial_state_helper.initial_states[switch_entity.key]
number_state = initial_state_helper.initial_states[number_entity.key]
text_state = initial_state_helper.initial_states[text_entity.key]
assert isinstance(switch_state, SwitchState)
assert isinstance(number_state, NumberState)
assert isinstance(text_state, TextState)
return switch_state, number_state, text_state
try:
# --- Run 1: entries under the object_id-hash keys, exactly as any
# earlier firmware wrote them. The restored states prove the key
# scheme has not drifted.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # bool: switch was ON
NUMBER_KEY: struct.pack("<f", 42.5),
TEXT_KEY: text_pref_payload("hello"),
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Switch state stored under the object_id preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the object_id preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the object_id preference key was lost"
)
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
# alongside the object_id entries. The object_id data must win; the
# beta entries are never read.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # current: ON
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
NUMBER_KEY: struct.pack("<f", 13.5), # current
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
TEXT_KEY: text_pref_payload("world"), # current
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Beta raw-name-key data overrode the object_id switch state"
)
assert number_state.state == 13.5, (
"Beta raw-name-key data overrode the object_id number value"
)
assert text_state.state == "world", (
"Beta raw-name-key data overrode the object_id text value"
)
finally:
clear_host_prefs(DEVICE_NAME)
+52 -5
View File
@@ -26,6 +26,7 @@ async def test_script_queued(
"stop": {"processed": [], "stop_logged": False},
"rejection": {"processed": [], "rejections": 0},
"no_params": {"executions": 0},
"boot": {"ended": []},
}
# Patterns for Test 1: Queue depth
@@ -49,12 +50,21 @@ async def test_script_queued(
# Patterns for Test 5: No params
no_params_end = re.compile(r"No params: END")
# Patterns for boot script (executed twice from on_boot before setup)
boot_end = re.compile(r"Boot queued: END (\d+)")
# Patterns for Test 6: Re-execute after stop
after_stop_end = re.compile(r"Stop test: END (\d+)")
# Test completion futures
boot_complete = loop.create_future()
test1_complete = loop.create_future()
test2_complete = loop.create_future()
test3_complete = loop.create_future()
test4_complete = loop.create_future()
test5_complete = loop.create_future()
test5_again_complete = loop.create_future()
test6_complete = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for all test messages."""
@@ -122,11 +132,24 @@ async def test_script_queued(
# Test 5: No params
if no_params_end.search(line):
test_results["no_params"]["executions"] += 1
if (
test_results["no_params"]["executions"] == 3
and not test5_complete.done()
):
test5_complete.set_result(True)
executions = test_results["no_params"]["executions"]
for count, future in ((3, test5_complete), (6, test5_again_complete)):
if executions == count and not future.done():
future.set_result(True)
# Boot script (queued from on_boot before setup)
if match := boot_end.search(line):
test_results["boot"]["ended"].append(int(match.group(1)))
if len(test_results["boot"]["ended"]) == 2 and not boot_complete.done():
boot_complete.set_result(True)
# Test 6: Re-execute after stop
if (
(match := after_stop_end.search(line))
and int(match.group(1)) == 9
and not test6_complete.done()
):
test6_complete.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
@@ -135,6 +158,13 @@ async def test_script_queued(
# Get services
_, services = await client.list_entities_services()
# Boot: both executions from on_boot must complete, including the one
# that was queued before QueueingScript::setup() ran
await asyncio.wait_for(boot_complete, timeout=2.0)
assert sorted(test_results["boot"]["ended"]) == [1, 2], (
f"Boot: Expected both on_boot executions to complete, got {sorted(test_results['boot']['ended'])}"
)
# Test 1: Queue depth limit
test_service = next((s for s in services if s.name == "test_queue_depth"), None)
assert test_service is not None, "test_queue_depth service not found"
@@ -203,3 +233,20 @@ async def test_script_queued(
assert test_results["no_params"]["executions"] == 3, (
f"Test 5: Expected 3 executions, got {test_results['no_params']['executions']}"
)
# Test 5 again: after the queue fully drained (loop disabled while
# idle), executing again must still work
test_service = next((s for s in services if s.name == "test_no_params"), None)
assert test_service is not None, "test_no_params service not found"
await client.execute_service(test_service, {})
await asyncio.wait_for(test5_again_complete, timeout=2.0)
assert test_results["no_params"]["executions"] == 6, (
f"Test 5 again: Expected 6 executions total, got {test_results['no_params']['executions']}"
)
# Test 6: a stopped script (queue cleared, loop disabled) must run
# again on the next execute; the future resolves only on "END 9"
test_service = next((s for s in services if s.name == "test_after_stop"), None)
assert test_service is not None, "test_after_stop service not found"
await client.execute_service(test_service, {})
await asyncio.wait_for(test6_complete, timeout=2.0)
@@ -0,0 +1,85 @@
"""Test that an idle queued script disables its loop and re-enables on demand."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_queued_idle_loop(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Assert the loop state transitions of a queued script via VV logs.
Expected sequence: the idle script disables its loop on the first
iteration after boot, re-enables it when an instance gets queued,
and disables it again once the queue drains.
"""
loop = asyncio.get_running_loop()
loop_state = re.compile(r"\bscript loop (disabled|enabled)\b")
script_end = re.compile(r"idle_script: END")
transitions: list[str] = []
end_count = 0
boot_disabled = loop.create_future()
enabled_after_queue = loop.create_future()
disabled_after_drain = loop.create_future()
runs_complete = loop.create_future()
def check_output(line: str) -> None:
nonlocal end_count
if match := loop_state.search(line):
transitions.append(match.group(1))
if transitions == ["disabled"] and not boot_disabled.done():
boot_disabled.set_result(True)
elif (
transitions == ["disabled", "enabled"]
and not enabled_after_queue.done()
):
enabled_after_queue.set_result(True)
elif (
transitions
== [
"disabled",
"enabled",
"disabled",
]
and not disabled_after_drain.done()
):
disabled_after_drain.set_result(True)
if script_end.search(line):
end_count += 1
if end_count == 2 and not runs_complete.done():
runs_complete.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# The idle script must disable its loop on the first iteration
await asyncio.wait_for(boot_disabled, timeout=5.0)
_, services = await client.list_entities_services()
run_twice = next((s for s in services if s.name == "run_twice"), None)
assert run_twice is not None, "run_twice service not found"
await client.execute_service(run_twice, {})
# Queueing the second instance must re-enable the loop
await asyncio.wait_for(enabled_after_queue, timeout=2.0)
# Both runs must complete and the drained queue must disable it again
await asyncio.wait_for(runs_complete, timeout=2.0)
await asyncio.wait_for(disabled_after_drain, timeout=2.0)
assert transitions == ["disabled", "enabled", "disabled"], (
f"Unexpected loop state sequence: {transitions}"
)
@@ -0,0 +1,49 @@
"""Integration test for headless SDL rendering and snapshot capture.
How a file is named and written is the same for every display that can take a snapshot and is
covered by test_snapshot_display; what is tested here is that SDL renders and can be read back
with no display server present.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from .bmp_utils import capture_when_drawn
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 101
HEIGHT = 64
@pytest.mark.asyncio
async def test_sdl_headless_screenshot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A headless SDL display renders with no display server and can be captured."""
snapshot_dir = tmp_path / "snapshots"
# The device reads this when it writes a file; the subprocess inherits our environment, so it
# must be set before the binary is launched.
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
# Make sure the run really is headless even when the test machine has a display.
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
service = next(s for s in services if s.name == "take_screenshot")
async def take(name: str) -> None:
await client.execute_service(service, {"name": name})
# The test card is drawn in several colours, so once it is on the screen the picture is
# not one flat shade. Capturing until that is true waits out the first update rather than
# racing it.
image, _ = await capture_when_drawn(take, snapshot_dir)
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
+35 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
@@ -25,6 +26,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
"filter_nan": [],
}
filter_min_done = loop.create_future()
@@ -32,16 +34,23 @@ async def test_sensor_filters_delta(
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
filter_nan_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
if not isinstance(state, SensorState):
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
if state.missing_state:
# Only the NaN test is interested in unavailable states
if sensor_name != "filter_nan":
return
sensor_values[sensor_name].append(math.nan)
else:
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
@@ -74,6 +83,12 @@ async def test_sensor_filters_delta(
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
elif (
sensor_name == "filter_nan"
and len(sensor_values[sensor_name]) == 3
and not filter_nan_done.done()
):
filter_nan_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -89,6 +104,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
"filter_nan": "Filter NaN",
},
)
@@ -108,13 +124,14 @@ async def test_sensor_filters_delta(
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
"Test Filter NaN": "filter_nan",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -186,3 +203,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)
# Test 6: NaN passes through once, then is suppressed
sensor_values["filter_nan"].clear()
client.button_command(buttons["filter_nan"])
try:
await asyncio.wait_for(filter_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}")
values = sensor_values["filter_nan"]
assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}"
assert math.isnan(values[1]), (
f"Test 6 failed: NaN not passed through, got {values}"
)
assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}"
@@ -0,0 +1,41 @@
"""Integration test for set_internal() called during and after setup."""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_set_internal_at_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""set_internal() in on_boot changes API exposure, later calls log an error."""
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
names = {entity.name for entity in entities}
assert "Hidden At Boot" not in names
assert "Shown At Boot" in names
assert "Untouched" in names
late = next(s for s in services if s.name == "set_internal_late")
await client.execute_service(late, {})
await waiter.wait_for(
"'Untouched'",
"set_internal() after setup is undefined behavior",
timeout=5.0,
)
# Still written during the deprecation window, ignored from 2027.3.0
entities, _ = await client.list_entities_services()
assert "Untouched" not in {entity.name for entity in entities}
@@ -0,0 +1,78 @@
"""Integration test for the snapshot display and the file writing shared with other displays."""
from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import LogLevel
import pytest
from .bmp_utils import capture_when_drawn, wait_for_bmp
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 101
HEIGHT = 64
# Part of the message the writer logs when it will not write over a file that is already there.
REFUSAL_MESSAGE = b"not overwriting"
@pytest.mark.asyncio
async def test_snapshot_display(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A display with no screen draws into memory and writes what it drew to a file."""
snapshot_dir = tmp_path / "snapshots"
# The device reads this when it writes a file; the subprocess inherits our environment, so it
# must be set before the binary is launched.
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
service = next(s for s in services if s.name == "take_snapshot")
async def take(name: str) -> None:
await client.execute_service(service, {"name": name})
# The test card is drawn in several colours, so once it is on the screen the picture is
# not one flat shade. Capturing until that is true waits out the first update rather than
# racing it.
image, capture = await capture_when_drawn(take, snapshot_dir)
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
# An extension is only added when there is not one already, whatever its case.
await take("UPPER.BMP")
await wait_for_bmp(snapshot_dir / "UPPER.BMP")
# A name that tries to lead somewhere else is cut back to one harmless name in the
# snapshot directory.
await take("../escape")
await wait_for_bmp(snapshot_dir / ".._escape.bmp")
# A second capture under a name already used must fail rather than write over the first.
# Wait for the device to report the refusal: on its own, an unchanged file cannot tell a
# refusal apart from a request the device has not got to yet, so a regression that wrote
# over the file could still pass on a busy machine.
refused = asyncio.Event()
def on_log(msg) -> None:
if REFUSAL_MESSAGE in msg.message:
refused.set()
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
before = capture.read_bytes()
await take(capture.name)
await asyncio.wait_for(refused.wait(), timeout=10.0)
assert capture.read_bytes() == before
# Nothing beyond what was asked for, leaving out however many captures it took to wait
# for the first frame.
written = sorted(
p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-")
)
assert written == [".._escape.bmp", "UPPER.BMP"]
@@ -8,7 +8,7 @@ client would silently drop the streamed bytes as "unknown message type".
The raw client implements just enough of the plaintext framing
(``0x00 | varint(size) | varint(msg_type) | payload``, see
``api_frame_helper_plaintext.cpp``) to send the empty `GetYamlRequest`
(message type 149) and accumulate every `GetYamlResponse` (message type 150)
(message type 154) and accumulate every `GetYamlResponse` (message type 155)
until ``done=true``.
"""
@@ -28,8 +28,8 @@ from .types import RunCompiledFunction
# Message IDs from esphome/components/api/api.proto.
HELLO_REQUEST = 1
HELLO_RESPONSE = 2
GET_YAML_REQUEST = 149
GET_YAML_RESPONSE = 150
GET_YAML_REQUEST = 154
GET_YAML_RESPONSE = 155
def _encode_varint(value: int) -> bytes:
@@ -0,0 +1,146 @@
"""Integration test for template climate: sensor-pushed measured values, on_control + publish
for the settable ones.
current_temperature/current_humidity are pushed by a referenced sensor/humidity_sensor (no
polling); action is set once at boot via climate.template.publish, since it has no sensor
equivalent. mode/target_temperature/fan_mode/swing_mode/preset are plain internal state:
on_control fires exactly once per command (never before the first one), and
climate.template.publish simulates the device reporting its own state independent of any prior
command -- that report is authoritative, overriding whatever was optimistically applied earlier.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateAction,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-basic"
@pytest.mark.asyncio
async def test_template_climate_basic(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Sensor-pushed measured values, on_control + publish for settable ones."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
# Advertised capabilities come straight from the supported_*/custom_* config lists.
assert ClimateMode.OFF in test_climate.supported_modes
assert ClimateMode.HEAT in test_climate.supported_modes
assert ClimateMode.COOL in test_climate.supported_modes
assert ClimateFanMode.AUTO in test_climate.supported_fan_modes
assert ClimateFanMode.LOW in test_climate.supported_fan_modes
assert ClimateFanMode.HIGH in test_climate.supported_fan_modes
assert ClimateSwingMode.OFF in test_climate.supported_swing_modes
assert ClimateSwingMode.VERTICAL in test_climate.supported_swing_modes
assert ClimatePreset.NONE in test_climate.supported_presets
assert ClimatePreset.ECO in test_climate.supported_presets
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.current_temperature == pytest.approx(22.5, abs=0.1)
assert initial.current_humidity == pytest.approx(55.0, abs=0.1)
assert initial.action == ClimateAction.IDLE
assert initial.mode == ClimateMode.OFF
# Nothing was commanded yet: on_control must not have fired.
assert not log_lines
# Commands apply optimistically and on_control fires with the same values.
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.HEAT
client.climate_command(test_climate.key, target_temperature=22.5)
state = await wait_for_climate_state()
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
client.climate_command(test_climate.key, fan_mode=ClimateFanMode.HIGH)
state = await wait_for_climate_state()
assert state.fan_mode == ClimateFanMode.HIGH
client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL)
state = await wait_for_climate_state()
assert state.swing_mode == ClimateSwingMode.VERTICAL
client.climate_command(test_climate.key, preset=ClimatePreset.ECO)
state = await wait_for_climate_state()
assert state.preset == ClimatePreset.ECO
await asyncio.sleep(0.2)
assert any(
"on_control mode=3" in line for line in log_lines
) # CLIMATE_MODE_HEAT
assert any("on_control target_temperature=22.5" in line for line in log_lines)
assert any("on_control fan_mode=" in line for line in log_lines)
assert any("on_control swing_mode=" in line for line in log_lines)
assert any("on_control preset=" in line for line in log_lines)
# Exactly one on_control log line per command, none extra (e.g. from a stray republish).
assert len(log_lines) == 5
# measured values are untouched by any of the above (no set action exists for them).
assert state.current_temperature == pytest.approx(22.5, abs=0.1)
assert state.current_humidity == pytest.approx(55.0, abs=0.1)
assert state.action == ClimateAction.IDLE
# The device's report is authoritative and overrides everything commanded above.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.OFF
assert state.fan_mode == ClimateFanMode.AUTO
assert state.swing_mode == ClimateSwingMode.OFF
assert state.preset == ClimatePreset.NONE
@@ -0,0 +1,98 @@
"""Integration test for template climate: custom fan modes and presets.
Same on_control (forward) + climate.template.publish (device report, authoritative) pattern as
the enum-based mode/preset fields, but for the custom string variants.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ButtonInfo, ClimateInfo
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-custom"
@pytest.mark.asyncio
async def test_template_climate_custom_modes(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Custom fan mode/preset: traits, on_control forwarding, and publish precedence."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
assert set(test_climate.supported_custom_fan_modes) == {
"turbo",
"silent",
"eco",
}
assert set(test_climate.supported_custom_presets) == {
"eco_plus",
"power_save",
"max",
}
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.custom_fan_mode == ""
assert initial.custom_preset == ""
client.climate_command(test_climate.key, custom_fan_mode="turbo")
state = await wait_for_climate_state()
assert state.custom_fan_mode == "turbo"
client.climate_command(test_climate.key, custom_preset="power_save")
state = await wait_for_climate_state()
assert state.custom_preset == "power_save"
await asyncio.sleep(0.2)
assert any("on_control custom_fan_mode=turbo" in line for line in log_lines)
assert any("on_control custom_preset=power_save" in line for line in log_lines)
# The device's report is authoritative and overrides what was commanded above.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.custom_fan_mode == "eco"
assert state.custom_preset == "max"
@@ -0,0 +1,107 @@
"""Integration test for template climate: optimistic: false.
A command still fires on_control (so a real device-backed config can forward it out), but must
NOT change the entity's own state -- only an explicit climate.template.publish call (standing in
for the device confirming the command actually took effect) does that.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-nonopt"
@pytest.mark.asyncio
async def test_template_climate_nonoptimistic(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Nonoptimistic: a command doesn't change state until explicitly published."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
state_updates: list[aioesphomeapi.ClimateState] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
confirm_button = require_entity(
entities, "simulate_device_confirmation", ButtonInfo
)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.mode == ClimateMode.OFF
# Send every settable field in one command. on_control must fire with all of them, but
# nothing may be applied to the entity's own state -- no ClimateState update at all.
client.climate_command(
test_climate.key,
mode=ClimateMode.HEAT,
target_temperature=22.5,
fan_mode=ClimateFanMode.HIGH,
swing_mode=ClimateSwingMode.VERTICAL,
preset=ClimatePreset.AWAY,
)
await asyncio.sleep(0.3)
assert any(
"on_control mode=3" in line for line in log_lines
) # CLIMATE_MODE_HEAT
assert any("on_control target_temperature=22.5" in line for line in log_lines)
assert any("on_control fan_mode=" in line for line in log_lines)
assert any("on_control swing_mode=" in line for line in log_lines)
assert any("on_control preset=" in line for line in log_lines)
assert not state_updates, (
"optimistic: false must not publish a state until climate.template.publish reports it"
)
# The device confirms the command actually took effect.
client.button_command(confirm_button.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.mode == ClimateMode.HEAT
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
assert state.fan_mode == ClimateFanMode.HIGH
assert state.swing_mode == ClimateSwingMode.VERTICAL
assert state.preset == ClimatePreset.AWAY
@@ -0,0 +1,83 @@
"""Integration test: on_control fires before control()/on_state, with the full ClimateCall.
on_control's lambda argument exposes get_mode()/etc. on the *requested* ClimateCall, while the
entity's own .mode field still reflects the state *before* control() applies the change --
proving the firing order is on_control, then control(), then on_state.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ClimateInfo, ClimateMode
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-oc-order"
@pytest.mark.asyncio
async def test_template_climate_on_control_ordering(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""on_control sees the requested value while the entity's own state is still the old one."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line or "on_state " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.HEAT
await asyncio.sleep(0.2)
# on_control saw the new requested mode (3 == CLIMATE_MODE_HEAT) while the entity's own
# state was still the old one (0 == CLIMATE_MODE_OFF) -- proving it fired before control().
assert any(
"on_control requested_mode=3 current_mode_before_apply=0" in line
for line in log_lines
)
# on_state fired afterward, reporting the now-applied mode.
assert any("on_state mode=3" in line for line in log_lines)
control_index = next(
i for i, line in enumerate(log_lines) if "on_control " in line
)
state_index = next(i for i, line in enumerate(log_lines) if "on_state " in line)
assert control_index < state_index, "on_control must fire before on_state"
@@ -0,0 +1,96 @@
"""Integration test for template climate: climate.template.publish covering every field at once.
A single climate.template.publish call resolves into exactly one ClimateState update, and never
triggers on_control (which would misrepresent a device state report as a fresh command). This also
exercises that a sensor/humidity_sensor whose reading matches what's about to be published doesn't
sneak in an extra state update of its own (the sensor callback only re-publishes on an actual
change).
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateAction,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-publish-all"
@pytest.mark.asyncio
async def test_template_climate_publish_all_fields(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""One climate.template.publish call setting every field resolves to one state update."""
clear_host_prefs(DEVICE_NAME)
state_updates: list[aioesphomeapi.ClimateState] = []
on_control_count = 0
def on_log_line(line: str) -> None:
nonlocal on_control_count
if "on_control fired" in line:
on_control_count += 1
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
publish_button = require_entity(entities, "publish_all", ButtonInfo)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
client.button_command(publish_button.key)
try:
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
except TimeoutError:
pytest.fail("Timeout waiting for the published climate state")
assert state.current_temperature == pytest.approx(20.0, abs=0.1)
assert state.current_humidity == pytest.approx(60.0, abs=0.1)
assert state.target_temperature == pytest.approx(23.0, abs=0.1)
assert state.mode == ClimateMode.HEAT
assert state.action == ClimateAction.HEATING
assert state.fan_mode == ClimateFanMode.HIGH
assert state.swing_mode == ClimateSwingMode.VERTICAL
assert state.preset == ClimatePreset.ECO
# Give any stray extra update (there shouldn't be one) a moment to arrive.
await asyncio.sleep(0.2)
assert len(state_updates) == 1, (
f"Expected exactly one ClimateState update, got {len(state_updates)}"
)
assert on_control_count == 0, (
"climate.template.publish must not trigger on_control"
)
@@ -0,0 +1,88 @@
"""Integration test for template climate: current_temperature/current_humidity live sensor push.
A *later* change to a backing sensor's value -- not just its initial reading at boot -- propagates
into a new climate state via add_on_state_callback. Re-publishing the same sensor value again must
not cause a redundant climate state update.
"""
from __future__ import annotations
import asyncio
import math
import aioesphomeapi
from aioesphomeapi import ButtonInfo, ClimateInfo
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-sensor-push"
@pytest.mark.asyncio
async def test_template_climate_sensor_push(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A later change to the backing sensor pushes a new climate state; an unchanged republish does not."""
clear_host_prefs(DEVICE_NAME)
state_updates: list[aioesphomeapi.ClimateState] = []
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
publish_temp = require_entity(entities, "publish_temperature", ButtonInfo)
publish_temp_same = require_entity(
entities, "publish_temperature_same", ButtonInfo
)
publish_humidity = require_entity(entities, "publish_humidity", ButtonInfo)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
# Neither backing sensor has published anything yet.
assert math.isnan(initial.current_temperature)
assert math.isnan(initial.current_humidity)
# A later sensor reading -- not the initial one -- pushes a new climate state.
client.button_command(publish_temp.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.current_temperature == pytest.approx(24.0, abs=0.1)
client.button_command(publish_humidity.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.current_humidity == pytest.approx(65.0, abs=0.1)
# Re-publishing the same temperature must not cause a redundant climate state update.
updates_before = len(state_updates)
client.button_command(publish_temp_same.key)
await asyncio.sleep(0.3)
assert len(state_updates) == updates_before, (
"Re-publishing an unchanged sensor reading must not republish the climate state"
)

Some files were not shown because too many files have changed in this diff Show More