Skip to content
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 Interlogix Ultrasync Alarm System Integration #42549

Closed
wants to merge 31 commits into from
Closed
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
28b5de0
Informix UltraSync Hub Support Added
caronc Oct 24, 2020
1a65f38
Informix UltraSync Hub Support Added
caronc Oct 24, 2020
e277a52
english translations added
caronc Oct 28, 2020
ad12e12
bumped version to handle new ultrasync (ha compatible)
caronc Oct 29, 2020
0423a28
support black formatting
caronc Oct 29, 2020
8a21c0c
tests and sanity added
caronc Oct 30, 2020
3b23444
isort and flake8/linting issues fixed
caronc Oct 30, 2020
ba1c170
typo; company is Interlogix
caronc Oct 30, 2020
2770ea3
not using device tracker; removed
caronc Oct 30, 2020
4bdf064
to use ultrasync v 0.8.1
caronc Oct 30, 2020
4d7e22d
ha python 3.7 unit tests fix
caronc Oct 30, 2020
c0a19f4
yaml configuration reference removed
caronc Oct 31, 2020
13b8e90
sensor tracking slightly updated
caronc Oct 31, 2020
d7643ff
bumped version to 0.9.0
caronc Nov 11, 2020
009eb10
improved test coverage
caronc Nov 13, 2020
55b4332
better zone testing coverage
caronc Nov 13, 2020
c97a2e0
removed unreference block of code
caronc Nov 13, 2020
6a2c66c
bumped version of ultrasync to v0.9.1
caronc Nov 22, 2020
f123eab
bumped version to 0.9.2
caronc Dec 6, 2020
db83a0e
backported enhancements in HACS branch to core
caronc Dec 13, 2020
f40a0af
black, isort and pep8 fixes
caronc Dec 13, 2020
ceb672b
automatically remove sensors no longer being monitored
caronc Dec 14, 2020
df39b56
bumped requried version of ultrasync
caronc Jan 31, 2021
9d91708
code cleanup as per review
caronc May 30, 2021
945e1ae
allow multiple panels per host as per review comments
caronc May 30, 2021
fc511af
updated exeption/error handling
caronc May 30, 2021
9a0ecd4
updated to use refresh() instead of reload()
caronc May 30, 2021
e465097
black validation added
caronc May 30, 2021
b348fc0
handle isort configuration
caronc May 30, 2021
4d05fe3
Merge branch 'dev' of github.com:caronc/core into dev
caronc Sep 20, 2021
ea0452b
added updates as per comments during review
caronc Sep 20, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ homeassistant/components/tuya/* @ollo69
homeassistant/components/twentemilieu/* @frenck
homeassistant/components/twinkly/* @dr1rrb
homeassistant/components/ubus/* @noltari
homeassistant/components/ultrasync/* @caronc
homeassistant/components/unifi/* @Kane610
homeassistant/components/unifiled/* @florisvdk
homeassistant/components/upb/* @gwww
Expand Down
143 changes: 143 additions & 0 deletions homeassistant/components/ultrasync/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""The Interlogix/Hills ComNav UltraSync Hub component."""

import asyncio
from datetime import timedelta

from ultrasync import AlarmScene
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import (
DATA_COORDINATOR,
DATA_UNDO_UPDATE_LISTENER,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
SENSORS,
SERVICE_AWAY,
SERVICE_DISARM,
SERVICE_STAY,
)
from .coordinator import UltraSyncDataUpdateCoordinator

PLATFORMS = ["sensor"]


async def async_setup(hass: HomeAssistantType, config: dict) -> bool:
"""Set up the UltraSync integration."""
hass.data.setdefault(DOMAIN, {})

return True
Comment on lines +28 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this anymore. We can move the code to async_setup_entry.

Suggested change
async def async_setup(hass: HomeAssistantType, config: dict) -> bool:
"""Set up the UltraSync integration."""
hass.data.setdefault(DOMAIN, {})
return True



async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up UltraSync from a config entry."""
if not entry.options:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not entry.options:
hass.data.setdefault(DOMAIN, {})
if not entry.options:

options = {
CONF_SCAN_INTERVAL: entry.data.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
}
hass.config_entries.async_update_entry(entry, options=options)

coordinator = UltraSyncDataUpdateCoordinator(
hass,
config=entry.data,
options=entry.options,
)

await coordinator.async_refresh()

if not coordinator.last_update_success:
raise ConfigEntryNotReady
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
await coordinator.async_config_entry_first_refresh()


undo_listener = entry.add_update_listener(_async_update_listener)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
undo_listener = entry.add_update_listener(_async_update_listener)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))


hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_UNDO_UPDATE_LISTENER: [undo_listener],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
DATA_UNDO_UPDATE_LISTENER: [undo_listener],

SENSORS: {},
}

for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)


_async_register_services(hass, coordinator)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_async_register_services(hass, coordinator)

return True


async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


if unload_ok:
for unsub in hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]:
unsub()
Comment on lines +71 to +72
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for unsub in hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]:
unsub()

hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok


def _async_register_services(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not create a alarm_control_panel entity? (https://developers.home-assistant.io/docs/core/entity/alarm-control-panel).

You can use the states armed_away, armed_home and disarmed. Creating your own implementation of this alarm is overkill (+ with alarm control panel you will benefit of nice extra features)

(An alarm_control_panel should be added as a follow up PR, since prs should contain just one platform).

hass: HomeAssistantType,
coordinator: UltraSyncDataUpdateCoordinator,
) -> None:
"""Register integration-level services."""

def away(call) -> None:
"""Service call to set alarm system to 'away' mode in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.AWAY)

def stay(call) -> None:
"""Service call to set alarm system to 'stay' mode in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.STAY)

def disarm(call) -> None:
"""Service call to disable alarm in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.DISARMED)

hass.services.async_register(DOMAIN, SERVICE_AWAY, away, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_STAY, stay, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_DISARM, disarm, schema=vol.Schema({}))
Comment on lines +78 to +98
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _async_register_services(
hass: HomeAssistantType,
coordinator: UltraSyncDataUpdateCoordinator,
) -> None:
"""Register integration-level services."""
def away(call) -> None:
"""Service call to set alarm system to 'away' mode in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.AWAY)
def stay(call) -> None:
"""Service call to set alarm system to 'stay' mode in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.STAY)
def disarm(call) -> None:
"""Service call to disable alarm in UltraSync Hub."""
coordinator.hub.set_alarm(state=AlarmScene.DISARMED)
hass.services.async_register(DOMAIN, SERVICE_AWAY, away, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_STAY, stay, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_DISARM, disarm, schema=vol.Schema({}))



async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None:
"""Handle options update."""
if entry.options[CONF_SCAN_INTERVAL]:
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
coordinator.update_interval = timedelta(
seconds=entry.options[CONF_SCAN_INTERVAL]
)

await coordinator.async_refresh()


class UltraSyncEntity(CoordinatorEntity):
"""Defines a base UltraSync entity."""

def __init__(
self, *, entry_id: str, name: str, coordinator: UltraSyncDataUpdateCoordinator
) -> None:
"""Initialize the UltraSync entity."""
super().__init__(coordinator)
self._name = name
self._entry_id = entry_id

@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
123 changes: 123 additions & 0 deletions homeassistant/components/ultrasync/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Config flow for the Interlogix/Hills ComNav UltraSync Hub."""
import logging
from typing import Any, Dict, Optional

import ultrasync
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PIN,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers.typing import ConfigType, HomeAssistantType

from .const import DEFAULT_NAME, DEFAULT_SCAN_INTERVAL
from .const import DOMAIN # pylint: disable=unused-import
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from .const import DOMAIN # pylint: disable=unused-import
from .const import DOMAIN


_LOGGER = logging.getLogger(__name__)


class AuthFailureException(Exception):
"""A general exception we can use to track Authentication failures."""

pass


def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]:
"""Validate the user input allows us to connect."""

usync = ultrasync.UltraSync(
host=data[CONF_HOST], user=data[CONF_USERNAME], pin=data[CONF_PIN]
)

# validate by attempting to authenticate with our hub

if not usync.login():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you the author of ultrasync package? It would be better to raise an error from the package and capture it here. Now all auth failures (no internet, wrong credentials, timeout) will all raise the same exception and thus the same error. This is not very helpful to the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am the author, yes. I agree it's not ideal; but 3 errors you identified that are all captured into one only play a role for the users initial hookup. Then it just works flawlessly from that point on.

There is no internet requirement here; the panel is usually in the persons local home network. So presuming the user is already accessing their panel internal on their network (via web browser), they're just entering the same 3 entries here too (host, pin and password). Basic troubleshooting would be to simply try the same host information in a new browser tab.

Either way, it's still a good suggestion, but I'd have to re-factor a section of the code to handle this sort of thing. Is this required to at the start to just get support for the Ultrasync Panel into HA?

# report our connection issue
raise AuthFailureException()

return True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to return anything. On failure this function will raise an exception.



class UltraSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""UltraSync config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL

@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return UltraSyncOptionsFlowHandler(config_entry)

async def async_step_user(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle user flow."""

errors = {}

if user_input is not None:
await self.async_set_unique_id(user_input.get(CONF_HOST))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self._abort_if_unique_id_configured()

try:
await self.hass.async_add_executor_job(
validate_input, self.hass, user_input
)

except AuthFailureException:
errors["base"] = "invalid_auth"

except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

else:
return self.async_create_entry(
title=user_input[CONF_HOST],
data=user_input,
)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PIN): str,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any specifications? (for example 4 digits?). Could be nice to specify this, an example from worxlandroid

vol.Required(CONF_PIN): vol.All(vol.Coerce(str), vol.Match(r"\d{4}")),

}
),
errors=errors,
)


class UltraSyncOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle UltraSync client options."""

def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry

async def async_step_init(self, user_input: Optional[ConfigType] = None):
"""Manage UltraSync options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)

options = {
vol.Optional(
CONF_SCAN_INTERVAL,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to configure scan interval? Can we set a sane default instead?

default=self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
): int,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you only want to accept positive numbers? I believe int can also be negative, but you would have to double check.

            ): cv.positive_int

}

return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
20 changes: 20 additions & 0 deletions homeassistant/components/ultrasync/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Constants the Interlogix/Hills ComNav UltraSync Hub."""

DOMAIN = "ultrasync"

# Scan Time (in seconds)
DEFAULT_SCAN_INTERVAL = 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally we don't allow lower scan interval than 5 seconds for polling integrations.


DEFAULT_NAME = "UltraSync"

# Services
SERVICE_AWAY = "away"
SERVICE_STAY = "stay"
SERVICE_DISARM = "disarm"

Comment on lines +10 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Services
SERVICE_AWAY = "away"
SERVICE_STAY = "stay"
SERVICE_DISARM = "disarm"
# Services

# Index used for managing loaded sensors
SENSORS = "sensors"

DATA_COORDINATOR = "coordinator"
DATA_UNDO_UPDATE_LISTENER = "undo_update_listener"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
DATA_UNDO_UPDATE_LISTENER = "undo_update_listener"

SENSOR_UPDATE_LISTENER = "ultrasync_update_sensors"
Loading