Add TRIGGERcmd integration (#121268)

* Initial commit with errors

* Commitable

* Use triggercmd user id as hub name

* Validate the token

* Use switch type, no trigger yet

* Working integration

* Use triggercmd module instead of httpx

* Add tests for triggercmd integration

* Add triggercmd to requirements_test_all.txt

* Add untested triggercmd files to .coveragerc

* Implement cgarwood's PR suggestions

* Address PR feedback

* Update homeassistant/components/triggercmd/config_flow.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/triggercmd/hub.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/triggercmd/strings.json

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/triggercmd/hub.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Get user id via triggercmd module, and better check for status 200 code

* PR feedback fixes

* Update homeassistant/components/triggercmd/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* More PR feedback fixes

* Update homeassistant/components/triggercmd/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* More PR feedback fixes

* Update tests/components/triggercmd/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Changes for PR feedback

* Changes to address PR comments

* Fix connection error when no internet

* Update homeassistant/components/triggercmd/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/triggercmd/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/triggercmd/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Updates for PR feedback

* Update tests/components/triggercmd/test_config_flow.py

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Russell VanderMey 2024-09-11 09:49:37 -04:00 committed by GitHub
parent f42bc3aaae
commit 79f3e30fb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 424 additions and 0 deletions

View File

@ -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

View File

@ -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)

View File

@ -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."""

View File

@ -0,0 +1,4 @@
"""Constants for the TRIGGERcmd integration."""
DOMAIN = "triggercmd"
CONF_TOKEN = "token"

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -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())

View File

@ -618,6 +618,7 @@ FLOWS = {
"trafikverket_train",
"trafikverket_weatherstation",
"transmission",
"triggercmd",
"tuya",
"twentemilieu",
"twilio",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the triggercmd integration."""

View File

@ -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

View File

@ -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