mirror of
https://github.com/esphome/esphome.git
synced 2026-09-24 05:24:14 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#include "sndbuf_pin_component.h"
|
||||
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
static const char *const TAG = "sndbuf_pin";
|
||||
|
||||
// Skip stdio; scan the low fd range where the listeners land
|
||||
static constexpr int FIRST_USER_FD = 3;
|
||||
static constexpr int MAX_FD_SCAN = 128;
|
||||
|
||||
void SndbufPinComponent::setup() {
|
||||
int pinned = 0;
|
||||
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
|
||||
int type = 0;
|
||||
socklen_t len = sizeof(type);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
|
||||
continue;
|
||||
struct sockaddr_in addr {};
|
||||
socklen_t addr_len = sizeof(addr);
|
||||
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
|
||||
continue;
|
||||
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
int applied = 0;
|
||||
len = sizeof(applied);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
|
||||
// Linux doubles the requested value; anything below it means clamped
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
|
||||
continue;
|
||||
}
|
||||
// Tests assert on this line; accepted sockets inherit the pinned size
|
||||
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
|
||||
applied);
|
||||
pinned++;
|
||||
}
|
||||
if (pinned == 0) {
|
||||
ESP_LOGE(TAG, "api listener socket was not pinned");
|
||||
this->mark_failed();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
// Test-only (host): pins SO_SNDBUF on every open TCP socket so integration
|
||||
// tests get deterministic backpressure; an explicit SO_SNDBUF also disables
|
||||
// kernel autotuning, and accepted sockets inherit it from the listener.
|
||||
class SndbufPinComponent : public Component {
|
||||
public:
|
||||
explicit SndbufPinComponent(int buffer_size) : buffer_size_(buffer_size) {}
|
||||
void setup() override;
|
||||
// After the api server so its listening socket exists
|
||||
float get_setup_priority() const override { return setup_priority::LATE; }
|
||||
|
||||
protected:
|
||||
int buffer_size_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
@@ -0,0 +1,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();"
|
||||
+60
-92
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user