diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index ab1389e13a..9658d3dc84 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -7,19 +7,23 @@ from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, homeassistant_ns, setup_home_assistant_entity, + validate_entity_domain, ) CODEOWNERS = ["@landonr"] DEPENDENCIES = ["api"] +SUPPORTED_DOMAINS = ["input_number", "number"] + HomeassistantNumber = homeassistant_ns.class_( "HomeassistantNumber", number.Number, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( number.number_schema(HomeassistantNumber) .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("number", SUPPORTED_DOMAINS), ) diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 965f91d202..553c2466f8 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -1,5 +1,7 @@ #include "homeassistant_number.h" +#include + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" @@ -80,12 +82,19 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); + static constexpr char INPUT_PREFIX[] = "input_"; static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); static constexpr auto VALUE_KEY = StringRef::from_lit("value"); api::HomeassistantActionRequest resp; - resp.service = SERVICE_NAME; + // input_number entities have their own set_value action + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = INPUT_NUMBER_SERVICE_NAME; + } else { + resp.service = NUMBER_SERVICE_NAME; + } resp.data.init(2); auto &entity_id = resp.data.emplace_back(); diff --git a/tests/component_tests/homeassistant/__init__.py b/tests/component_tests/homeassistant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/homeassistant/test_number.py b/tests/component_tests/homeassistant/test_number.py new file mode 100644 index 0000000000..d17842307d --- /dev/null +++ b/tests/component_tests/homeassistant/test_number.py @@ -0,0 +1,32 @@ +"""Tests for the homeassistant number platform.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +VALID_CONFIG = "tests/component_tests/homeassistant/test_number.yaml" +INVALID_CONFIG = "tests/component_tests/homeassistant/test_number_invalid_domain.yaml" + + +def test_number_and_input_number_entities( + generate_main: Callable[[str | Path], str], +) -> None: + """Both number and input_number entity IDs are accepted and passed to the C++ class.""" + main_cpp = generate_main(VALID_CONFIG) + + assert 'ha_number->set_entity_id("number.some_number");' in main_cpp + assert 'ha_input_number->set_entity_id("input_number.some_input");' in main_cpp + + +def test_number_rejects_unsupported_domain( + capsys: pytest.CaptureFixture[str], +) -> None: + """An entity ID from another domain fails config validation.""" + CORE.config_path = Path(INVALID_CONFIG) + + assert read_config({}) is None + assert "is not supported by the number platform" in capsys.readouterr().out diff --git a/tests/component_tests/homeassistant/test_number.yaml b/tests/component_tests/homeassistant/test_number.yaml new file mode 100644 index 0000000000..bcd8365495 --- /dev/null +++ b/tests/component_tests/homeassistant/test_number.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +host: + +logger: + +api: + +number: + - platform: homeassistant + entity_id: number.some_number + id: ha_number + - platform: homeassistant + entity_id: input_number.some_input + id: ha_input_number diff --git a/tests/component_tests/homeassistant/test_number_invalid_domain.yaml b/tests/component_tests/homeassistant/test_number_invalid_domain.yaml new file mode 100644 index 0000000000..684de60620 --- /dev/null +++ b/tests/component_tests/homeassistant/test_number_invalid_domain.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +host: + +logger: + +api: + +number: + - platform: homeassistant + entity_id: number.some_number + id: ha_number + - platform: homeassistant + entity_id: sensor.foo + id: ha_other_number diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 1099f7ea85..9bc0defae2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -71,6 +71,9 @@ number: - platform: homeassistant entity_id: number.hello_world id: ha_hello_world_number + - platform: homeassistant + entity_id: input_number.hello_world + id: ha_hello_world_input_number sensor: - platform: homeassistant diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index 2d77821ff3..deea19bdeb 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -21,6 +21,12 @@ api: - number.set: id: ha_number value: 42.5 + - number.set: + id: ha_real_number + value: 12.5 + - number.set: + id: ha_real_input_number + value: 7 - switch.turn_on: ha_switch - switch.turn_off: ha_switch - logger.log: "=== All tests completed ===" @@ -129,6 +135,13 @@ text_sensor: # Number component for testing HA number control number: + # Real homeassistant numbers: the action used depends on the entity domain + - platform: homeassistant + id: ha_real_number + entity_id: number.test_real_number + - platform: homeassistant + id: ha_real_input_number + entity_id: input_number.test_real_input_number - platform: template name: "HA Controlled Number" id: ha_number diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index b4adedf873..bb99b41813 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -86,6 +86,15 @@ async def test_api_homeassistant( "nonexistent.action_for_error_test": loop.create_future(), # error_test_call } + # Futures keyed by (service, entity_id) for the real homeassistant numbers + ha_real_number_futures = { + ("number.set_value", "number.test_real_number"): loop.create_future(), + ( + "input_number.set_value", + "input_number.test_real_input_number", + ): loop.create_future(), + } + # Future for error message test action_error_received_future = loop.create_future() @@ -102,6 +111,10 @@ async def test_api_homeassistant( if not future.done(): future.set_result(service_call) + key = (service_call.service, service_call.data.get("entity_id")) + if key in ha_real_number_futures and not ha_real_number_futures[key].done(): + ha_real_number_futures[key].set_result(service_call) + # Immediately respond to the error test call so the test can proceed # This needs to happen synchronously so ESPHome receives the response # before logging "=== All tests completed ===" @@ -201,6 +214,16 @@ async def test_api_homeassistant( # Empty state with valid entity should work (use different entity to not interfere with test) client.send_home_assistant_state("sensor.edge_case_empty_state", "", "") + # The real homeassistant numbers reject values outside min/max, which + # default to 0 until Home Assistant provides them + for entity in ( + "number.test_real_number", + "input_number.test_real_input_number", + ): + client.send_home_assistant_state(entity, "min", "0") + client.send_home_assistant_state(entity, "max", "100") + client.send_home_assistant_state(entity, "step", "0.5") + # List entities and services _, services = await client.list_entities_services() @@ -334,6 +357,21 @@ async def test_api_homeassistant( # The value might be formatted with trailing zeros assert float(number_call.data["value"]) == 42.5 + # 7b. Real homeassistant numbers pick the action from the entity domain + real_number_call = await asyncio.wait_for( + ha_real_number_futures[("number.set_value", "number.test_real_number")], + timeout=2.0, + ) + assert float(real_number_call.data["value"]) == 12.5 + + real_input_number_call = await asyncio.wait_for( + ha_real_number_futures[ + ("input_number.set_value", "input_number.test_real_input_number") + ], + timeout=2.0, + ) + assert float(real_input_number_call.data["value"]) == 7 + # 8. HA Switch service calls switch_on_call = await asyncio.wait_for( service_call_futures["switch.turn_on"], timeout=2.0