New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Remootio integration #63155
base: dev
Are you sure you want to change the base?
Add Remootio integration #63155
Changes from all commits
72ed83f
0a63b42
c4f9583
ab7addc
d906872
bc483dc
4c6cf9c
1c35a75
4d79011
a392017
0bb357e
af69d84
c098bfa
5077c98
0f0312c
ed5a2dc
4864a95
70c4a70
a0c3059
5682939
541ee26
de882b3
4b6212e
4b5c272
ad0bcda
9412d8d
964a871
4788e55
e35d785
205c3c6
07be815
c664fbd
d52ba0b
c295b76
c978d01
d944651
820fbd2
9f07c8a
b3cbe90
ca49fb9
86d6e34
6935c68
1fe27b1
44e1ab7
66ea842
550df9f
6baae92
01fafc0
8e0ef5c
9f73129
ffd7ba1
37571b6
fe32fd1
1712872
b43f7c0
0fd038a
bd64835
ced8e5b
4c0932b
ee279b1
9ebc4c6
0cf86ba
116d986
532d071
8481008
d088fac
662af0b
ab81bd7
b66d9cf
00bebf0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
"""The Remootio integration.""" | ||
from __future__ import annotations | ||
|
||
import logging | ||
|
||
from aioremootio import ConnectionOptions, RemootioClient | ||
|
||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, Platform | ||
from homeassistant.core import HomeAssistant, callback | ||
|
||
from .const import ( | ||
ATTR_SERIAL_NUMBER, | ||
ATTR_TYPE, | ||
CONF_API_AUTH_KEY, | ||
CONF_API_SECRET_KEY, | ||
CONF_SERIAL_NUMBER, | ||
DOMAIN, | ||
EVENT_HANDLER_CALLBACK, | ||
EVENT_TYPE, | ||
REMOOTIO_CLIENT, | ||
) | ||
from .cover import RemootioCoverEvent | ||
from .utils import create_client | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
PLATFORMS = [Platform.COVER] | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up Remootio from a config entry.""" | ||
|
||
_LOGGER.debug("Doing async_setup_entry. entry [%s]", entry.as_dict()) | ||
|
||
@callback | ||
def handle_event(event: RemootioCoverEvent) -> None: | ||
_LOGGER.debug( | ||
"Firing event. EvenType [%s] RemootioCoverEntityId [%s] RemootioDeviceSerialNumber [%s]", | ||
event.type, | ||
event.entity_id, | ||
event.device_serial_number, | ||
) | ||
|
||
hass.bus.async_fire( | ||
EVENT_TYPE, | ||
{ | ||
ATTR_ENTITY_ID: event.entity_id, | ||
ATTR_SERIAL_NUMBER: event.device_serial_number, | ||
ATTR_NAME: event.entity_name, | ||
ATTR_TYPE: event.type, | ||
}, | ||
) | ||
|
||
connection_options: ConnectionOptions = ConnectionOptions( | ||
entry.data[CONF_HOST], | ||
entry.data[CONF_API_SECRET_KEY], | ||
entry.data[CONF_API_AUTH_KEY], | ||
False, | ||
) | ||
serial_number: str = entry.data[CONF_SERIAL_NUMBER] | ||
|
||
remootio_client: RemootioClient = await create_client( | ||
hass, connection_options, _LOGGER, serial_number | ||
) | ||
|
||
async def terminate_client() -> None: | ||
_LOGGER.debug( | ||
"Remootio client will now be terminated. entry [%s]", entry.as_dict() | ||
) | ||
|
||
terminated: bool = await remootio_client.terminate() | ||
if terminated: | ||
_LOGGER.debug( | ||
"Remootio client successfully terminated. entry [%s]", entry.as_dict() | ||
) | ||
|
||
entry.async_on_unload(terminate_client) | ||
|
||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { | ||
REMOOTIO_CLIENT: remootio_client, | ||
EVENT_HANDLER_CALLBACK: handle_event, | ||
} | ||
|
||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
||
return True | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Unload a config entry.""" | ||
|
||
_LOGGER.debug( | ||
"Doing async_unload_entry. entry [%s] hass.data[%s][%s] [%s]", | ||
entry.as_dict(), | ||
DOMAIN, | ||
entry.entry_id, | ||
hass.data.get(DOMAIN, {}).get(entry.entry_id, {}), | ||
) | ||
|
||
platforms_unloaded = await hass.config_entries.async_unload_platforms( | ||
entry, PLATFORMS | ||
) | ||
|
||
return platforms_unloaded |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,179 @@ | ||||||
"""Config flow for Remootio integration.""" | ||||||
from __future__ import annotations | ||||||
|
||||||
import logging | ||||||
from typing import Any | ||||||
|
||||||
from aioremootio import ( | ||||||
ConnectionOptions, | ||||||
RemootioClientAuthenticationError, | ||||||
RemootioClientConnectionEstablishmentError, | ||||||
) | ||||||
from aioremootio.constants import ( | ||||||
CONNECTION_OPTION_REGEX_API_AUTH_KEY, | ||||||
CONNECTION_OPTION_REGEX_API_SECRET_KEY, | ||||||
CONNECTION_OPTION_REGEX_HOST, | ||||||
) | ||||||
import voluptuous as vol | ||||||
from voluptuous.error import RequiredFieldInvalid | ||||||
from voluptuous.schema_builder import REMOVE_EXTRA | ||||||
|
||||||
from homeassistant import config_entries | ||||||
from homeassistant.components.cover import CoverDeviceClass | ||||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST | ||||||
from homeassistant.core import HomeAssistant | ||||||
from homeassistant.data_entry_flow import FlowResult | ||||||
|
||||||
from .const import ( | ||||||
CONF_API_AUTH_KEY, | ||||||
CONF_API_SECRET_KEY, | ||||||
CONF_DATA, | ||||||
CONF_SERIAL_NUMBER, | ||||||
CONF_TITLE, | ||||||
DOMAIN, | ||||||
) | ||||||
from .exceptions import UnsupportedRemootioDeviceError | ||||||
from .utils import get_serial_number | ||||||
|
||||||
_LOGGER = logging.getLogger(__name__) | ||||||
|
||||||
INPUT_VALIDATION_SCHEMA = vol.Schema( | ||||||
{ | ||||||
vol.Required(CONF_HOST, msg="Host is required"): vol.All( | ||||||
vol.Coerce(str), | ||||||
vol.Match(CONNECTION_OPTION_REGEX_HOST), | ||||||
msg="Host appears to be invalid; it can be an IP address or a host name that complies with RFC-1123", | ||||||
), | ||||||
vol.Required(CONF_API_SECRET_KEY, msg="API Secret Key is required"): vol.All( | ||||||
vol.Coerce(str), | ||||||
vol.Upper, | ||||||
vol.Match(CONNECTION_OPTION_REGEX_API_SECRET_KEY), | ||||||
msg="API Secret Key appears to be invalid; it must be a sequence of 64 characters and can contain only numbers and english letters", | ||||||
), | ||||||
vol.Required(CONF_API_AUTH_KEY, msg="API Auth Key is required"): vol.All( | ||||||
vol.Coerce(str), | ||||||
vol.Upper, | ||||||
vol.Match(CONNECTION_OPTION_REGEX_API_AUTH_KEY), | ||||||
msg="API Auth Key appears to be invalid; it must be a sequence of 64 characters and can contain only numbers and english letters", | ||||||
), | ||||||
vol.Required( | ||||||
CONF_DEVICE_CLASS, | ||||||
default=CoverDeviceClass.GARAGE, | ||||||
msg="Controlled device's class is required", | ||||||
): vol.All( | ||||||
vol.Coerce(str), | ||||||
vol.In([CoverDeviceClass.GARAGE, CoverDeviceClass.GATE]), | ||||||
msg="Controlled device's class appears to be invalid", | ||||||
Comment on lines
+45
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you should need all these messages. You can use the data_description field in the strings.json to give the user some more info. Downside to these messages is that they cant be translated There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This messages are only for logging purposes, the messages for the user comes from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's for logging, there's no need to have such detailed error messages though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry @emontnemery, but I can't share this opinion. Why are log messages like the following ones not needed?
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feedback to the user about how to fill in the form should happen via the UI not via error messages in the log. Your examples, where keys are is missing, are unrealistic because the UI will not allow the user to submit the form if not filled in.
For the values, there's again no need to customize the messages because user feedback happens via the UI. I guess it's OK to keep the custom messages if you really want them, but it's an unnecessary burden to maintain because the natural language error messages now need to be maintained both in |
||||||
), | ||||||
}, | ||||||
extra=REMOVE_EXTRA, | ||||||
) | ||||||
|
||||||
DEVICE_NAME = "Remootio Device" | ||||||
|
||||||
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: | ||||||
"""Validate the user input.""" | ||||||
|
||||||
_LOGGER.debug("Validating input... Input [%s]", data) | ||||||
|
||||||
data = INPUT_VALIDATION_SCHEMA(data) | ||||||
|
||||||
connection_options: ConnectionOptions = ConnectionOptions( | ||||||
data[CONF_HOST], data[CONF_API_SECRET_KEY], data[CONF_API_AUTH_KEY] | ||||||
) | ||||||
|
||||||
device_serial_number: str = await get_serial_number( | ||||||
hass, connection_options, _LOGGER | ||||||
) | ||||||
|
||||||
data[CONF_SERIAL_NUMBER] = device_serial_number | ||||||
|
||||||
return { | ||||||
CONF_TITLE: f"{DEVICE_NAME} (Host: {data[CONF_HOST]}, S/N: {device_serial_number})", | ||||||
CONF_DATA: data, | ||||||
} | ||||||
|
||||||
|
||||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||||||
"""Handle a config flow for Remootio.""" | ||||||
|
||||||
VERSION = 1 | ||||||
|
||||||
async def async_step_user( | ||||||
self, user_input: dict[str, Any] | None = None | ||||||
) -> FlowResult: | ||||||
"""Handle the user step.""" | ||||||
user_input = user_input or {} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to do this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parameter There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree, this is not needed if combined with the corresponding change below
Suggested change
|
||||||
errors = {} | ||||||
|
||||||
if len(user_input) != 0: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't check len, just check if the user input is truthy, if it is, it's dict with all required keys
Suggested change
|
||||||
validation_result = {} | ||||||
|
||||||
try: | ||||||
validation_result = await validate_input(self.hass, user_input) | ||||||
except UnsupportedRemootioDeviceError: | ||||||
_LOGGER.debug("Remootio device isn't supported", exc_info=True) | ||||||
return self.async_abort(reason="unsupported_device") | ||||||
except vol.MultipleInvalid as ex: | ||||||
_LOGGER.error( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no reason why the user should see this in their log, please lower severity to debug
Suggested change
|
||||||
"Invalid user input. MultipleInvalid.Errors [%s]", ex.errors | ||||||
) | ||||||
for error in ex.errors: | ||||||
_LOGGER.debug( | ||||||
"Error [%s] Path [%s]", error.__class__.__name__, error.path[0] | ||||||
) | ||||||
if isinstance(error, RequiredFieldInvalid): | ||||||
errors[str(error.path[0])] = f"{error.path[0]}_required" | ||||||
else: | ||||||
errors[str(error.path[0])] = f"{error.path[0]}_invalid" | ||||||
Comment on lines
+122
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you need to do this yourself anymore, please take a look at #108075 |
||||||
except RemootioClientConnectionEstablishmentError: | ||||||
_LOGGER.error("Can't connect to Remootio device") | ||||||
errors["base"] = "cannot_connect" | ||||||
except RemootioClientAuthenticationError: | ||||||
_LOGGER.error("Can't authenticate by the Remootio device") | ||||||
errors["base"] = "invalid_auth" | ||||||
except Exception: # pylint: disable=broad-except | ||||||
_LOGGER.exception("Unexpected exception/error") | ||||||
errors["base"] = "unknown" | ||||||
else: | ||||||
await self.async_set_unique_id( | ||||||
validation_result[CONF_DATA][CONF_SERIAL_NUMBER] | ||||||
) | ||||||
self._abort_if_unique_id_configured(validation_result[CONF_DATA]) | ||||||
|
||||||
return self.async_create_entry( | ||||||
title=validation_result[CONF_TITLE], | ||||||
data=validation_result[CONF_DATA], | ||||||
) | ||||||
|
||||||
return self.async_show_form( | ||||||
step_id="user", | ||||||
data_schema=vol.Schema( | ||||||
{ | ||||||
vol.Optional( | ||||||
CONF_HOST, | ||||||
default=user_input.get(CONF_HOST, vol.UNDEFINED), | ||||||
): vol.Coerce(str), | ||||||
vol.Optional( | ||||||
CONF_API_SECRET_KEY, | ||||||
default=user_input.get(CONF_API_SECRET_KEY, vol.UNDEFINED), | ||||||
): vol.Coerce(str), | ||||||
vol.Optional( | ||||||
CONF_API_AUTH_KEY, | ||||||
default=user_input.get(CONF_API_AUTH_KEY, vol.UNDEFINED), | ||||||
): vol.Coerce(str), | ||||||
Comment on lines
+154
to
+165
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are they all optional? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The user should not have to look at error messages in the log to understand how to fill in the schema, please make the fields required here as requested by @joostlek |
||||||
vol.Optional( | ||||||
CONF_DEVICE_CLASS, | ||||||
default=user_input.get( | ||||||
CONF_DEVICE_CLASS, CoverDeviceClass.GARAGE | ||||||
), | ||||||
): vol.All( | ||||||
vol.Coerce(str), | ||||||
vol.In([CoverDeviceClass.GARAGE, CoverDeviceClass.GATE]), | ||||||
), | ||||||
ivgg-me marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+166
to
+174
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned in another comment, please remove the device class setting from here |
||||||
}, | ||||||
extra=REMOVE_EXTRA, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this? Where do the extra keys come from? |
||||||
), | ||||||
errors=errors, | ||||||
) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,34 @@ | ||||||
"""Constants for the Remootio integration.""" | ||||||
from homeassistant.const import CONF_TYPE | ||||||
|
||||||
# Domain of the integration | ||||||
DOMAIN = "remootio" | ||||||
|
||||||
# Event type of the integration | ||||||
EVENT_TYPE = f"{DOMAIN.lower()}_event" | ||||||
|
||||||
# Timeout used in methods of remootio.utils | ||||||
REMOOTIO_TIMEOUT = 60 | ||||||
|
||||||
# Deleay used in methods of remootio.utils | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
REMOOTIO_DELAY = 0.5 | ||||||
|
||||||
# Expected minimum Remootio Websocket API version supported by this integration | ||||||
EXPECTED_MINIMUM_API_VERSION = 2 | ||||||
|
||||||
# Keys used by the Config flow | ||||||
CONF_API_SECRET_KEY = "secret__api_secret_key" | ||||||
CONF_API_AUTH_KEY = "secret__api_auth_key" | ||||||
CONF_TITLE = "title" | ||||||
CONF_SERIAL_NUMBER = "serial_number" | ||||||
CONF_DATA = "data" | ||||||
|
||||||
# Keys for event data fired by remootio.cover.RemootioCoverEventListener | ||||||
ATTR_SERIAL_NUMBER = CONF_SERIAL_NUMBER | ||||||
ATTR_TYPE = CONF_TYPE | ||||||
|
||||||
# Key for the dictionary entry which holds the instance of aioremootio.RemootioClient to connect to the Remootio device using the Remootio Websocket API | ||||||
REMOOTIO_CLIENT = "remootio_client" | ||||||
|
||||||
# Key for the dictionary entry which holds reference to the callback to be invoked to handle events triggered by the Remootio device | ||||||
EVENT_HANDLER_CALLBACK = "event_handler_callback" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove the custom event for now, this functionality should be modeled as an event entity instead, in a follow-up PR: https://developers.home-assistant.io/docs/core/entity/event
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How should I then fire events based on the events that the Remootio fires and on which the user may want to set up an automation, e.g. if the controlled garden gate or garage door remains open for a time that can be configured on the Remootio?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please look into event entities which I linked to: https://developers.home-assistant.io/docs/core/entity/event/, I think it will fit this use case very well.