Add support for nexia automations (#33049)

* Add support for nexia automations

Bump nexia to 0.7.1

Start adding tests

Fix some of the climate attributes that were wrong (discovered while adding tests)

Pass the name of the instance so the nexia UI does not display "My Mobile"

* fix mocking

* faster asserts, scene

* scene makes so much more sense

* pylint

* Update homeassistant/components/nexia/scene.py

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* docstring cleanup

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/33074/head
J. Nick Koston 2020-03-20 18:49:42 -05:00 committed by GitHub
parent 836413a4a8
commit 85328399e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 8362 additions and 7 deletions

View File

@ -62,7 +62,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
try:
nexia_home = await hass.async_add_executor_job(
partial(NexiaHome, username=username, password=password)
partial(
NexiaHome,
username=username,
password=password,
device_name=hass.config.location_name,
)
)
except ConnectTimeout as ex:
_LOGGER.error("Unable to connect to Nexia service: %s", ex)

View File

@ -10,6 +10,7 @@ from nexia.const import (
SYSTEM_STATUS_COOL,
SYSTEM_STATUS_HEAT,
SYSTEM_STATUS_IDLE,
UNIT_FAHRENHEIT,
)
from homeassistant.components.climate import ClimateDevice
@ -32,6 +33,7 @@ from homeassistant.components.climate.const import (
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_HUMIDITY,
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.const import (
ATTR_ATTRIBUTION,
@ -119,7 +121,12 @@ class NexiaZone(NexiaEntity, ClimateDevice):
@property
def supported_features(self):
"""Return the list of supported features."""
supported = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE
supported = (
SUPPORT_TARGET_TEMPERATURE_RANGE
| SUPPORT_TARGET_TEMPERATURE
| SUPPORT_FAN_MODE
| SUPPORT_PRESET_MODE
)
if self._has_humidify_support or self._has_dehumidify_support:
supported |= SUPPORT_TARGET_HUMIDITY
@ -159,6 +166,16 @@ class NexiaZone(NexiaEntity, ClimateDevice):
"""Return the list of available fan modes."""
return FAN_MODES
@property
def min_temp(self):
"""Minimum temp for the current setting."""
return (self._device.thermostat.get_setpoint_limits())[0]
@property
def max_temp(self):
"""Maximum temp for the current setting."""
return (self._device.thermostat.get_setpoint_limits())[1]
def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
self.thermostat.set_fan_mode(fan_mode)
@ -198,8 +215,37 @@ class NexiaZone(NexiaEntity, ClimateDevice):
@property
def target_temperature(self):
"""Temperature we try to reach."""
if self._device.get_current_mode() == "COOL":
current_mode = self._device.get_current_mode()
if current_mode == OPERATION_MODE_COOL:
return self._device.get_cooling_setpoint()
if current_mode == OPERATION_MODE_HEAT:
return self._device.get_heating_setpoint()
return None
@property
def target_temperature_step(self):
"""Step size of temperature units."""
if self._device.thermostat.get_unit() == UNIT_FAHRENHEIT:
return 1.0
return 0.5
@property
def target_temperature_high(self):
"""Highest temperature we are trying to reach."""
current_mode = self._device.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._device.get_cooling_setpoint()
@property
def target_temperature_low(self):
"""Lowest temperature we are trying to reach."""
current_mode = self._device.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._device.get_heating_setpoint()
@property

View File

@ -26,6 +26,7 @@ async def validate_input(hass: core.HomeAssistant, data):
password=data[CONF_PASSWORD],
auto_login=False,
auto_update=False,
device_name=hass.config.location_name,
)
await hass.async_add_executor_job(nexia_home.login)
except ConnectTimeout as ex:

View File

@ -1,6 +1,6 @@
"""Nexia constants."""
PLATFORMS = ["sensor", "binary_sensor", "climate"]
PLATFORMS = ["sensor", "binary_sensor", "climate", "scene"]
ATTRIBUTION = "Data provided by mynexia.com"
@ -14,6 +14,8 @@ NEXIA_SCAN_INTERVAL = "scan_interval"
DOMAIN = "nexia"
DEFAULT_ENTITY_NAMESPACE = "nexia"
ATTR_DESCRIPTION = "description"
ATTR_ZONE_STATUS = "zone_status"
ATTR_HUMIDIFY_SUPPORTED = "humidify_supported"
ATTR_DEHUMIDIFY_SUPPORTED = "dehumidify_supported"

View File

@ -2,7 +2,7 @@
"domain": "nexia",
"name": "Nexia",
"requirements": [
"nexia==0.4.1"
"nexia==0.7.1"
],
"dependencies": [],
"codeowners": [

View File

@ -0,0 +1,68 @@
"""Support for Nexia Automations."""
from homeassistant.components.scene import Scene
from homeassistant.const import ATTR_ATTRIBUTION
from .const import (
ATTR_DESCRIPTION,
ATTRIBUTION,
DATA_NEXIA,
DOMAIN,
NEXIA_DEVICE,
UPDATE_COORDINATOR,
)
from .entity import NexiaEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up automations for a Nexia device."""
nexia_data = hass.data[DOMAIN][config_entry.entry_id][DATA_NEXIA]
nexia_home = nexia_data[NEXIA_DEVICE]
coordinator = nexia_data[UPDATE_COORDINATOR]
entities = []
# Automation switches
for automation_id in nexia_home.get_automation_ids():
automation = nexia_home.get_automation_by_id(automation_id)
entities.append(NexiaAutomationScene(coordinator, automation))
async_add_entities(entities, True)
class NexiaAutomationScene(NexiaEntity, Scene):
"""Provides Nexia automation support."""
def __init__(self, coordinator, automation):
"""Initialize the automation scene."""
super().__init__(coordinator)
self._automation = automation
@property
def unique_id(self):
"""Return the unique id of the automation scene."""
# This is the automation unique_id
return self._automation.automation_id
@property
def name(self):
"""Return the name of the automation scene."""
return self._automation.name
@property
def device_state_attributes(self):
"""Return the scene specific state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
ATTR_DESCRIPTION: self._automation.description,
}
@property
def icon(self):
"""Return the icon of the automation scene."""
return "mdi:script-text-outline"
def activate(self):
"""Activate an automation scene."""
self._automation.activate()

View File

@ -917,7 +917,7 @@ netdisco==2.6.0
neurio==0.3.1
# homeassistant.components.nexia
nexia==0.4.1
nexia==0.7.1
# homeassistant.components.niko_home_control
niko-home-control==0.2.1

View File

@ -344,7 +344,7 @@ nessclient==0.9.15
netdisco==2.6.0
# homeassistant.components.nexia
nexia==0.4.1
nexia==0.7.1
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.0.10

View File

@ -0,0 +1,45 @@
"""The lock tests for the august platform."""
from homeassistant.components.climate.const import HVAC_MODE_HEAT_COOL
from .util import async_init_integration
async def test_climate_zones(hass):
"""Test creation climate zones."""
await async_init_integration(hass)
state = hass.states.get("climate.nick_office")
assert state.state == HVAC_MODE_HEAT_COOL
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"current_humidity": 52.0,
"current_temperature": 22.8,
"dehumidify_setpoint": 45.0,
"dehumidify_supported": True,
"fan_mode": "auto",
"fan_modes": ["auto", "on", "circulate"],
"friendly_name": "Nick Office",
"humidify_supported": False,
"humidity": 45.0,
"hvac_action": "cooling",
"hvac_modes": ["off", "auto", "heat_cool", "heat", "cool"],
"max_humidity": 65.0,
"max_temp": 37.2,
"min_humidity": 35.0,
"min_temp": 12.8,
"preset_mode": "None",
"preset_modes": ["None", "Home", "Away", "Sleep"],
"supported_features": 31,
"target_temp_high": 26.1,
"target_temp_low": 17.2,
"target_temp_step": 1.0,
"temperature": None,
"zone_status": "Relieving Air",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)

View File

@ -0,0 +1,72 @@
"""The lock tests for the august platform."""
from .util import async_init_integration
async def test_automation_scenees(hass):
"""Test creation automation scenees."""
await async_init_integration(hass)
state = hass.states.get("scene.away_short")
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"description": "When IFTTT activates the automation Upstairs "
"West Wing will permanently hold the heat to 63.0 "
"and cool to 80.0 AND Downstairs East Wing will "
"permanently hold the heat to 63.0 and cool to "
"79.0 AND Downstairs West Wing will permanently "
"hold the heat to 63.0 and cool to 79.0 AND "
"Upstairs West Wing will permanently hold the "
"heat to 63.0 and cool to 81.0 AND Upstairs West "
"Wing will change Fan Mode to Auto AND Downstairs "
"East Wing will change Fan Mode to Auto AND "
"Downstairs West Wing will change Fan Mode to "
"Auto AND Activate the mode named 'Away Short' "
"AND Master Suite will permanently hold the heat "
"to 63.0 and cool to 79.0 AND Master Suite will "
"change Fan Mode to Auto",
"friendly_name": "Away Short",
"icon": "mdi:script-text-outline",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("scene.power_outage")
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"description": "When IFTTT activates the automation Upstairs "
"West Wing will permanently hold the heat to 55.0 "
"and cool to 90.0 AND Downstairs East Wing will "
"permanently hold the heat to 55.0 and cool to "
"90.0 AND Downstairs West Wing will permanently "
"hold the heat to 55.0 and cool to 90.0 AND "
"Activate the mode named 'Power Outage'",
"friendly_name": "Power Outage",
"icon": "mdi:script-text-outline",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("scene.power_restored")
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"description": "When IFTTT activates the automation Upstairs "
"West Wing will Run Schedule AND Downstairs East "
"Wing will Run Schedule AND Downstairs West Wing "
"will Run Schedule AND Activate the mode named "
"'Home'",
"friendly_name": "Power Restored",
"icon": "mdi:script-text-outline",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)

View File

@ -0,0 +1,45 @@
"""Tests for the nexia integration."""
import uuid
from asynctest import patch
from nexia.home import NexiaHome
import requests_mock
from homeassistant.components.nexia.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
async def async_init_integration(
hass: HomeAssistant, skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the nexia integration in Home Assistant."""
house_fixture = "nexia/mobile_houses_123456.json"
session_fixture = "nexia/session_123456.json"
sign_in_fixture = "nexia/sign_in.json"
with requests_mock.mock() as m, patch(
"nexia.home.load_or_create_uuid", return_value=uuid.uuid4()
):
m.post(NexiaHome.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture))
m.get(
NexiaHome.API_MOBILE_HOUSES_URL.format(house_id=123456),
text=load_fixture(house_fixture),
)
m.post(
NexiaHome.API_MOBILE_ACCOUNTS_SIGN_IN_URL,
text=load_fixture(sign_in_fixture),
)
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"success" : true,
"result" : {
"is_activated_by_activation_code" : 0,
"can_receive_notifications" : true,
"can_manage_locks" : true,
"can_control_automations" : true,
"_links" : {
"child" : [
{
"data" : {
"name" : "House",
"postal_code" : "12345",
"id" : 123456
}
}
],
"self" : {
"href" : "https://www.mynexia.com/mobile/session"
}
},
"can_view_videos" : true
},
"error" : null
}

10
tests/fixtures/nexia/sign_in.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"success": true,
"error": null,
"result": {
"mobile_id": 1,
"api_key": "mock",
"setup_step": "done",
"locale": "en_us"
}
}