diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03eadb5f0a..c067df237f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2c3219e38e..8c830d99c7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index cf0fca06e7..dea0fad449 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_ } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else diff --git a/requirements.txt b/requirements.txt index 71aaf47ddb..7a50e1296f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.2 +tornado==6.5.3 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 diff --git a/tests/integration/fixtures/text_command.yaml b/tests/integration/fixtures/text_command.yaml new file mode 100644 index 0000000000..cc91e2f792 --- /dev/null +++ b/tests/integration/fixtures/text_command.yaml @@ -0,0 +1,37 @@ +esphome: + name: host-text-command-test + +host: + +api: + batch_delay: 0ms + +logger: + +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "initial" + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 4 + max_length: 32 + mode: password + initial_value: "secret" + + - platform: template + name: "Test Text Long" + id: test_text_long + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "" diff --git a/tests/integration/test_text_command.py b/tests/integration/test_text_command.py new file mode 100644 index 0000000000..82fe981578 --- /dev/null +++ b/tests/integration/test_text_command.py @@ -0,0 +1,126 @@ +"""Integration test for text command zero-copy optimization. + +Tests that TextCommandRequest correctly handles the pointer_to_buffer +optimization for the state field, ensuring text values are properly +transmitted via the API. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test text command with various string values including edge cases.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-text-command-test" + + # Get list of entities + entities, _ = await client.list_entities_services() + + # Find our text entities using require_entity + test_text = require_entity(entities, "test_text", TextInfo, "Test Text entity") + test_password = require_entity( + entities, "test_password", TextInfo, "Test Password entity" + ) + test_text_long = require_entity( + entities, "test_text_long", TextInfo, "Test Text Long entity" + ) + + # Track state changes + states: dict[int, Any] = {} + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + states[state.key] = state + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + # Set up InitialStateHelper to swallow initial state broadcasts + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be received + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify initial states were received + assert test_text.key in initial_state_helper.initial_states + initial_text_state = initial_state_helper.initial_states[test_text.key] + assert isinstance(initial_text_state, TextState) + assert initial_text_state.state == "initial" + + async def wait_for_state_change(key: int, timeout: float = 2.0) -> Any: + """Wait for a state change for the given entity key.""" + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Test 1: Simple text value + client.text_command(key=test_text.key, state="hello world") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello world" + + # Test 2: Empty string (edge case for zero-copy) + client.text_command(key=test_text.key, state="") + state = await wait_for_state_change(test_text.key) + assert state.state == "" + + # Test 3: Single character + client.text_command(key=test_text.key, state="x") + state = await wait_for_state_change(test_text.key) + assert state.state == "x" + + # Test 4: String with special characters + client.text_command(key=test_text.key, state="hello\tworld\n!") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello\tworld\n!" + + # Test 5: Unicode characters + client.text_command(key=test_text.key, state="hello δΈ–η•Œ 🌍") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello δΈ–η•Œ 🌍" + + # Test 6: Long string (tests buffer handling) + long_text = "a" * 200 + client.text_command(key=test_text_long.key, state=long_text) + state = await wait_for_state_change(test_text_long.key) + assert state.state == long_text + assert len(state.state) == 200 + + # Test 7: Password field (same mechanism, different mode) + client.text_command(key=test_password.key, state="newpassword123") + state = await wait_for_state_change(test_password.key) + assert state.state == "newpassword123" + + # Test 8: String with null bytes embedded (edge case) + # Note: protobuf strings should handle this but it's good to verify + client.text_command(key=test_text.key, state="before\x00after") + state = await wait_for_state_change(test_text.key) + assert state.state == "before\x00after" + + # Test 9: Rapid successive commands (tests buffer reuse) + for i in range(5): + client.text_command(key=test_text.key, state=f"rapid_{i}") + state = await wait_for_state_change(test_text.key) + assert state.state == f"rapid_{i}"