mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 09:47:52 +00:00
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:
parent
f42bc3aaae
commit
79f3e30fb6
@ -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
|
||||
|
36
homeassistant/components/triggercmd/__init__.py
Normal file
36
homeassistant/components/triggercmd/__init__.py
Normal 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)
|
75
homeassistant/components/triggercmd/config_flow.py
Normal file
75
homeassistant/components/triggercmd/config_flow.py
Normal 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."""
|
4
homeassistant/components/triggercmd/const.py
Normal file
4
homeassistant/components/triggercmd/const.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Constants for the TRIGGERcmd integration."""
|
||||
|
||||
DOMAIN = "triggercmd"
|
||||
CONF_TOKEN = "token"
|
10
homeassistant/components/triggercmd/manifest.json
Normal file
10
homeassistant/components/triggercmd/manifest.json
Normal 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"]
|
||||
}
|
22
homeassistant/components/triggercmd/strings.json
Normal file
22
homeassistant/components/triggercmd/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
85
homeassistant/components/triggercmd/switch.py
Normal file
85
homeassistant/components/triggercmd/switch.py
Normal 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())
|
@ -618,6 +618,7 @@ FLOWS = {
|
||||
"trafikverket_train",
|
||||
"trafikverket_weatherstation",
|
||||
"transmission",
|
||||
"triggercmd",
|
||||
"tuya",
|
||||
"twentemilieu",
|
||||
"twilio",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/triggercmd/__init__.py
Normal file
1
tests/components/triggercmd/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the triggercmd integration."""
|
15
tests/components/triggercmd/conftest.py
Normal file
15
tests/components/triggercmd/conftest.py
Normal 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
|
161
tests/components/triggercmd/test_config_flow.py
Normal file
161
tests/components/triggercmd/test_config_flow.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user