diff --git a/CODEOWNERS b/CODEOWNERS index 1f03fc5ed96..fdb7069069d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1540,6 +1540,8 @@ build.json @home-assistant/supervisor /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede +/homeassistant/components/triggercmd/ @rvmey +/tests/components/triggercmd/ @rvmey /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck diff --git a/homeassistant/components/triggercmd/__init__.py b/homeassistant/components/triggercmd/__init__.py new file mode 100644 index 00000000000..f58b2b481d4 --- /dev/null +++ b/homeassistant/components/triggercmd/__init__.py @@ -0,0 +1,36 @@ +"""The TRIGGERcmd component.""" + +from __future__ import annotations + +from triggercmd import client, ha + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_TOKEN + +PLATFORMS = [ + Platform.SWITCH, +] + +type TriggercmdConfigEntry = ConfigEntry[ha.Hub] + + +async def async_setup_entry(hass: HomeAssistant, entry: TriggercmdConfigEntry) -> bool: + """Set up TRIGGERcmd from a config entry.""" + hub = ha.Hub(entry.data[CONF_TOKEN]) + + status_code = await client.async_connection_test(entry.data[CONF_TOKEN]) + if status_code != 200: + raise ConfigEntryNotReady + + entry.runtime_data = hub + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TriggercmdConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py new file mode 100644 index 00000000000..f39d3abc9d4 --- /dev/null +++ b/homeassistant/components/triggercmd/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for TRIGGERcmd integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from triggercmd import TRIGGERcmdConnectionError, client +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({(CONF_TOKEN): str}) + + +async def validate_input(hass: HomeAssistant, data: dict) -> str: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + if len(data[CONF_TOKEN]) < 100: + raise InvalidToken + + token_data = jwt.decode(data[CONF_TOKEN], options={"verify_signature": False}) + if not token_data["id"]: + raise InvalidToken + + try: + await client.async_connection_test(data[CONF_TOKEN]) + except Exception as e: + raise TRIGGERcmdConnectionError from e + else: + return token_data["id"] + + +class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + identifier = await validate_input(self.hass, user_input) + except InvalidToken: + errors[CONF_TOKEN] = "invalid_token" + except TRIGGERcmdConnectionError: + errors["base"] = "connection_error" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="TRIGGERcmd Hub", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidToken(HomeAssistantError): + """Invalid token.""" diff --git a/homeassistant/components/triggercmd/const.py b/homeassistant/components/triggercmd/const.py new file mode 100644 index 00000000000..0fc15b2b806 --- /dev/null +++ b/homeassistant/components/triggercmd/const.py @@ -0,0 +1,4 @@ +"""Constants for the TRIGGERcmd integration.""" + +DOMAIN = "triggercmd" +CONF_TOKEN = "token" diff --git a/homeassistant/components/triggercmd/manifest.json b/homeassistant/components/triggercmd/manifest.json new file mode 100644 index 00000000000..b71a5b83a81 --- /dev/null +++ b/homeassistant/components/triggercmd/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "triggercmd", + "name": "TRIGGERcmd", + "codeowners": ["@rvmey"], + "config_flow": true, + "documentation": "https://docs.triggercmd.com", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["triggercmd==0.0.27"] +} diff --git a/homeassistant/components/triggercmd/strings.json b/homeassistant/components/triggercmd/strings.json new file mode 100644 index 00000000000..cbbbbc312be --- /dev/null +++ b/homeassistant/components/triggercmd/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "token": "The token from the TRIGGERcmd instructions page" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py new file mode 100644 index 00000000000..94566fe301d --- /dev/null +++ b/homeassistant/components/triggercmd/switch.py @@ -0,0 +1,85 @@ +"""Platform for switch integration.""" + +from __future__ import annotations + +import logging + +from triggercmd import client, ha + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TriggercmdConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TriggercmdConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add switch for passed config_entry in HA.""" + hub = config_entry.runtime_data + async_add_entities(TRIGGERcmdSwitch(trigger) for trigger in hub.triggers) + + +class TRIGGERcmdSwitch(SwitchEntity): + """Representation of a Switch.""" + + _attr_has_entity_name = True + _attr_assumed_state = True + _attr_should_poll = False + + computer_id: str + trigger_id: str + firmware_version: str + model: str + hub: ha.Hub + + def __init__(self, trigger: TRIGGERcmdSwitch) -> None: + """Initialize the switch.""" + self._switch = trigger + self._attr_is_on = False + self._attr_unique_id = f"{trigger.computer_id}.{trigger.trigger_id}" + self._attr_name = trigger.trigger_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, trigger.computer_id)}, + name=trigger.computer_id.capitalize(), + sw_version=trigger.firmware_version, + model=trigger.model, + manufacturer=trigger.hub.manufacturer, + ) + + @property + def available(self) -> bool: + """Return True if hub is available.""" + return self._switch.hub.online + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self.trigger("on") + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self.trigger("off") + self._attr_is_on = False + self.async_write_ha_state() + + async def trigger(self, params: str): + """Trigger the command.""" + r = await client.async_trigger( + self._switch.hub.token, + { + "computer": self._switch.computer_id, + "trigger": self._switch.trigger_id, + "params": params, + "sender": "Home Assistant", + }, + ) + _LOGGER.debug("TRIGGERcmd trigger response: %s", r.json()) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 351f9e8e2e5..a0fb9a48a17 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -618,6 +618,7 @@ FLOWS = { "trafikverket_train", "trafikverket_weatherstation", "transmission", + "triggercmd", "tuya", "twentemilieu", "twilio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1e518cfe3aa..62e77d0edb1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6460,6 +6460,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "triggercmd": { + "name": "TRIGGERcmd", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tuya": { "name": "Tuya", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f88ed31e89a..22fba3efe18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2835,6 +2835,9 @@ tplink-omada-client==1.4.2 # homeassistant.components.transmission transmission-rpc==7.0.3 +# homeassistant.components.triggercmd +triggercmd==0.0.27 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 507362eb7df..34b9892885e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2242,6 +2242,9 @@ tplink-omada-client==1.4.2 # homeassistant.components.transmission transmission-rpc==7.0.3 +# homeassistant.components.triggercmd +triggercmd==0.0.27 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/tests/components/triggercmd/__init__.py b/tests/components/triggercmd/__init__.py new file mode 100644 index 00000000000..90562a67386 --- /dev/null +++ b/tests/components/triggercmd/__init__.py @@ -0,0 +1 @@ +"""Tests for the triggercmd integration.""" diff --git a/tests/components/triggercmd/conftest.py b/tests/components/triggercmd/conftest.py new file mode 100644 index 00000000000..5e2ac250d61 --- /dev/null +++ b/tests/components/triggercmd/conftest.py @@ -0,0 +1,15 @@ +"""triggercmd conftest.""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_async_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.triggercmd.async_setup_entry", + return_value=True, + ) as mock_async_setup_entry: + yield mock_async_setup_entry diff --git a/tests/components/triggercmd/test_config_flow.py b/tests/components/triggercmd/test_config_flow.py new file mode 100644 index 00000000000..51f3730ab1a --- /dev/null +++ b/tests/components/triggercmd/test_config_flow.py @@ -0,0 +1,161 @@ +"""Define tests for the triggercmd config flow.""" + +from unittest.mock import patch + +import pytest +from triggercmd import TRIGGERcmdConnectionError + +from homeassistant.components.triggercmd.const import CONF_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +invalid_token_with_length_100_or_more = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMzQ1Njc4OTBxd2VydHl1aW9wYXNkZiIsImlhdCI6MTcxOTg4MTU4M30.E4T2S4RQfuI2ww74sUkkT-wyTGrV5_VDkgUdae5yo4E" +invalid_token_id = "1234567890qwertyuiopasdf" +invalid_token_with_length_100_or_more_and_no_id = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub2lkIjoiMTIzNDU2Nzg5MHF3ZXJ0eXVpb3Bhc2RmIiwiaWF0IjoxNzE5ODgxNTgzfQ.MaJLNWPGCE51Zibhbq-Yz7h3GkUxLurR2eoM2frnO6Y" + + +async def test_full_flow( + hass: HomeAssistant, +) -> None: + """Test config flow happy path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["errors"] == {} + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["data"] == {CONF_TOKEN: invalid_token_with_length_100_or_more} + assert result["result"].unique_id == invalid_token_id + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (invalid_token_with_length_100_or_more_and_no_id, {"base": "unknown"}), + ("not-a-token", {CONF_TOKEN: "invalid_token"}), + ], +) +async def test_config_flow_user_invalid_token( + hass: HomeAssistant, + test_input: str, + expected: dict, +) -> None: + """Test the initial step of the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: test_input}, + ) + + assert result["errors"] == expected + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_config_flow_entry_already_configured(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + MockConfigEntry( + domain=DOMAIN, + data={CONF_TOKEN: invalid_token_with_length_100_or_more}, + unique_id=invalid_token_id, + ).add_to_hass(hass) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + + +async def test_config_flow_connection_error(hass: HomeAssistant) -> None: + """Test a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + side_effect=TRIGGERcmdConnectionError, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["errors"] == { + "base": "connection_error", + } + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY