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 support for ElkM1 alarm/automation panel #16952

Merged
merged 17 commits into from
Oct 7, 2018
165 changes: 165 additions & 0 deletions homeassistant/components/alarm_control_panel/elkm1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""
Each ElkM1 area will be created as a separate alarm_control_panel in HASS.

For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/alarm_control_panel.elkm1/
"""

import voluptuous as vol
import homeassistant.helpers.config_validation as cv
import homeassistant.components.alarm_control_panel as alarm
from homeassistant.const import (ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING,
STATE_ALARM_DISARMED, STATE_ALARM_PENDING,
STATE_ALARM_TRIGGERED, STATE_UNKNOWN)

from homeassistant.components.elkm1 import (DOMAIN, create_elk_devices,
Copy link
Member

Choose a reason for hiding this comment

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

Import the elkm1 domain as another name. This platform belongs to the alarm_control_panel domain.

ElkDeviceBase,
register_elk_service)
from elkm1_lib.const import AlarmState, ArmedStatus, ArmLevel, ArmUpState
Copy link
Member

Choose a reason for hiding this comment

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

This won't work. Requirement library names must only be imported within functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This does work. Is there a reason for importing only within functions?

Copy link
Contributor

Choose a reason for hiding this comment

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

I understand the normal reasoning (that things would crash if trying to import a library not yet installed by HASS as a dependency). In this case it won't cause that problem (though it breaks with the established pattern) since trying to load the component by itself probably fails terribly.

The alarm_control_panel.elkm1 (and light and so on) never get loaded unless they were loaded by the top-level elkm1 component being configured, which means the library must be installed, which makes this safe (even if not "how it's done").

Even if we made things get imported only in functions, you couldn't load alarm_control_panel by itself without configuring the elkm1 platform.

If we have to move those imports into functions it means a lot of repetitive imports. If we have to, we have to, it's just annoying and ugly (and maybe impacts performance, but I imagine that after the first import the repeats are nearly zero cost).

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So what is established is:

  1. There's a rule.
  2. The rule doesn't take into account when a platform derives from a base that has a requirement.
  3. The Elk alarm_control_panel has no REQUIREMENTS because the REQUIREMENTS are in the base.
  4. The Elk base elkm1/__init__.py follows the rule because the code won't run if the rule isn't followed (and, yah, I stumbled on this and it took an hour to figure out).

What would have to happen to improve the rule?

Copy link
Member

@MartinHjelmare MartinHjelmare Oct 3, 2018

Choose a reason for hiding this comment

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

The rule is there for a reason. We can't guard users from configuring a platform directly instead of the component. If the users would do that, there would be an error, since the platform is imported before the dependencies are loaded and requirements installed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank-you. That helps.


DEPENDENCIES = [DOMAIN]

STATE_ALARM_ARMED_VACATION = 'armed_vacation'
Copy link
Member

Choose a reason for hiding this comment

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

This isn't used.

STATE_ALARM_ARMED_HOME_INSTANT = 'armed_home_instant'
Copy link
Member

Choose a reason for hiding this comment

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

This isn't used.

STATE_ALARM_ARMED_NIGHT_INSTANT = 'armed_night_instant'
Copy link
Member

Choose a reason for hiding this comment

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

This isn't used.

Copy link
Contributor

Choose a reason for hiding this comment

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

The extra states aren't used yet because we couldn't use them on the old regular HASS UI, without them being added to polymer, etc.

We've been trying to hammer out what additional states are needed so we can get them added to HASS properly.

See home-assistant/architecture#54 for more on that, if you're curious.


SERVICE_TO_ELK = {
'alarm_arm_vacation': 'async_alarm_arm_vacation',
'alarm_arm_home_instant': 'async_alarm_arm_home_instant',
'alarm_arm_night_instant': 'async_alarm_arm_night_instant',
}

DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema({
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional('clear', default=2): vol.In([0, 1, 2]),
vol.Optional('beep', default=False): cv.boolean,
vol.Optional('timeout', default=0): vol.Range(min=0, max=65535),
vol.Optional('line1', default=''): cv.string,
vol.Optional('line2', default=''): cv.string,
})

ELK_STATE_TO_HASS_STATE = {
Copy link
Member

Choose a reason for hiding this comment

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

Move this to within a function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is there a reason why?

Copy link
Member

Choose a reason for hiding this comment

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

ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED,
ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY,
ArmedStatus.ARMED_STAY.value: STATE_ALARM_ARMED_HOME,
ArmedStatus.ARMED_STAY_INSTANT.value: STATE_ALARM_ARMED_HOME,
ArmedStatus.ARMED_TO_NIGHT.value: STATE_ALARM_ARMED_NIGHT,
ArmedStatus.ARMED_TO_NIGHT_INSTANT.value: STATE_ALARM_ARMED_NIGHT,
ArmedStatus.ARMED_TO_VACATION.value: STATE_ALARM_ARMED_AWAY,
}


# pylint: disable=unused-argument
Copy link
Member

Choose a reason for hiding this comment

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

This is already globally disabled.

async def async_setup_platform(hass, config, async_add_devices,
Copy link
Member

Choose a reason for hiding this comment

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

Rename async_add_devices to async_add_entities.

discovery_info):
"""Setup the ElkM1 alarm platform."""

elk = hass.data[DOMAIN]['elk']
devices = create_elk_devices(hass, elk.areas, 'area', ElkArea, [])
async_add_devices(devices, True)

for service, method in SERVICE_TO_ELK.items():
register_elk_service(
hass, alarm.DOMAIN, service, alarm.ALARM_SERVICE_SCHEMA, method)

register_elk_service(
hass, alarm.DOMAIN, 'alarm_display_message',
DISPLAY_MESSAGE_SERVICE_SCHEMA, 'async_alarm_display_message')

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.

Nothing is checking this return value, so we can remove the statement.



class ElkArea(ElkDeviceBase, alarm.AlarmControlPanel):
"""Representation of an Area / Partition within the ElkM1 alarm panel."""

def __init__(self, device, hass, config):
"""Initialize Area as Alarm Control Panel."""
ElkDeviceBase.__init__(self, 'alarm_control_panel', device,
Copy link
Member

Choose a reason for hiding this comment

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

Use super().__init__(...).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tried that before. Doesn't work. Here's the error

  File "/Users/glenn/Development/automation/home-assistant/homeassistant/components/alarm_control_panel/elkm1.py", line 55, in async_setup_platform
    devices = create_elk_devices(hass, elk.areas, 'area', ElkArea, [])
  File "/Users/glenn/Development/automation/home-assistant/homeassistant/components/elkm1/__init__.py", line 176, in create_elk_devices
    devices.append(class_(element, hass, config[element_type]))
  File "/Users/glenn/Development/automation/home-assistant/homeassistant/components/alarm_control_panel/elkm1.py", line 75, in __init__
    hass, config)
TypeError: __init__() takes 5 positional arguments but 6 were given

Copy link
Member

Choose a reason for hiding this comment

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

How did you try it? Did you include self. It shouldn't be included.

hass, config)
self._changed_by_entity_id = ''

for keypad in self._elk.keypads:
keypad.add_callback(self._watch_keypad)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

def _watch_keypad(self, keypad, changeset):
if keypad.area != self._element.index:
return
if changeset.get('last_user') is not None:
self._changed_by_entity_id = self._hass.data[
DOMAIN]['keypads'].get(keypad.index, '')
self.async_schedule_update_ha_state(True)

@property
def code_format(self):
"""Return the alarm code format."""
return '^[0-9]{4}([0-9]{2})?$'

@property
def device_state_attributes(self):
"""Attributes of the area."""
attrs = self.initial_attrs()
elmt = self._element
attrs['is_exit'] = elmt.is_exit
attrs['timer1'] = elmt.timer1
attrs['timer2'] = elmt.timer2
attrs['armed_status'] = STATE_UNKNOWN if elmt.armed_status is None \
Copy link
Member

Choose a reason for hiding this comment

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

Unknown state attributes are normally not reported at all.

Copy link
Contributor

Choose a reason for hiding this comment

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

Then what happens if someone has an automation that checks the attribute, and it doesn't exist?

Copy link
Member

Choose a reason for hiding this comment

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

else ArmedStatus(elmt.armed_status).name.lower()
attrs['arm_up_state'] = STATE_UNKNOWN if elmt.arm_up_state is None \
else ArmUpState(elmt.arm_up_state).name.lower()
attrs['alarm_state'] = STATE_UNKNOWN if elmt.alarm_state is None \
else AlarmState(elmt.alarm_state).name.lower()
attrs['changed_by_entity_id'] = self._changed_by_entity_id
return attrs

# pylint: disable=unused-argument
def _element_changed(self, element, changeset):
if self._element.alarm_state is None:
self._state = STATE_UNKNOWN
Copy link
Member

Choose a reason for hiding this comment

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

Use None to represent unknown state. The base entity class will handle this and convert to the correct state name.

elif self._area_is_in_alarm_state():
self._state = STATE_ALARM_TRIGGERED
elif self._entry_exit_timer_is_running():
self._state = STATE_ALARM_ARMING \
if self._element.is_exit else STATE_ALARM_PENDING
else:
self._state = ELK_STATE_TO_HASS_STATE[self._element.armed_status]

def _entry_exit_timer_is_running(self):
return self._element.timer1 > 0 or self._element.timer2 > 0

def _area_is_in_alarm_state(self):
return self._element.alarm_state >= AlarmState.FIRE_ALARM.value

async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
self._element.disarm(int(code))

async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
self._element.arm(ArmLevel.ARMED_STAY.value, int(code))

async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
self._element.arm(ArmLevel.ARMED_AWAY.value, int(code))

async def async_alarm_arm_night(self, code=None):
"""Send arm away command."""
self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code))

async def async_alarm_arm_home_instant(self, code):
"""Send arm vacation command."""
self._element.arm(ArmLevel.ARMED_STAY_INSTANT.value, int(code))

async def async_alarm_arm_night_instant(self, code):
"""Send arm vacation command."""
self._element.arm(ArmLevel.ARMED_VACATION.value, int(code))

async def async_alarm_arm_vacation(self, code):
"""Send arm vacation command."""
self._element.arm(ArmLevel.ARMED_VACATION.value, int(code))

async def async_alarm_display_message(
self, clear, beep, timeout, line1, line2):
"""Display a message on all keypads for the area."""
self._element.display_message(clear, beep, timeout, line1, line2)
226 changes: 226 additions & 0 deletions homeassistant/components/climate/elkm1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""
Support for control of ElkM1 connected thermostats.

For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/climate.elkm1/
"""

from homeassistant.components.climate import (ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
PRECISION_WHOLE, STATE_AUTO,
STATE_COOL, STATE_FAN_ONLY,
STATE_HEAT, STATE_IDLE,
SUPPORT_AUX_HEAT,
SUPPORT_FAN_MODE,
SUPPORT_OPERATION_MODE,
SUPPORT_TARGET_TEMPERATURE_HIGH,
SUPPORT_TARGET_TEMPERATURE_LOW,
ClimateDevice)
from homeassistant.const import (STATE_ON, STATE_UNKNOWN)

from homeassistant.components.elkm1 import (DOMAIN, ElkDeviceBase,
create_elk_devices)
from elkm1_lib.const import ThermostatFan, ThermostatMode, ThermostatSetting

DEPENDENCIES = [DOMAIN]

SUPPORT_FLAGS = (
SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW |
SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT)


# pylint: disable=unused-argument
async def async_setup_platform(hass, config, async_add_devices,
discovery_info):
"""Setup the Elk switch platform."""
elk = hass.data[DOMAIN]['elk']
async_add_devices(create_elk_devices(hass, elk.thermostats, 'thermostat',
ElkThermostat, []), True)
return True


# pylint: disable=too-many-public-methods
class ElkThermostat(ElkDeviceBase, ClimateDevice):
"""Elk connected thermostat as Climate device."""
def __init__(self, device, hass, config):
"""Initialize Thermostat."""
ElkDeviceBase.__init__(self, 'climate', device, hass, config)

# pylint: disable=unused-argument
def _element_changed(self, element, changeset):
pass

@property
def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_FLAGS

@property
def temperature_unit(self):
"""Return the unit of measurement."""
return self._temperature_unit

@property
def unit_of_measurement(self):
"""Return the unit of measurement to display."""
return self.temperature_unit

@property
def current_temperature(self):
"""Return the current temperature."""
return self._element.current_temp

@property
def state(self):
"""Return the current state."""
# We can't actually tell if it's actively running in any of these
# modes, just what mode is set
if (self._element.mode == ThermostatMode.OFF.value) and (
self._element.fan == ThermostatFan.ON.value):
return STATE_FAN_ONLY
if self._element.mode == ThermostatMode.OFF.value:
return STATE_IDLE
if (self._element.mode == ThermostatMode.HEAT.value) or (
self._element.mode == ThermostatMode.EMERGENCY_HEAT.value):
return STATE_HEAT
if self._element.mode == ThermostatMode.COOL.value:
return STATE_COOL
if self._element.mode == ThermostatMode.AUTO.value:
return STATE_AUTO
return STATE_UNKNOWN

@property
def precision(self):
"""Return the precision of the system."""
return PRECISION_WHOLE

@property
def current_humidity(self):
"""Return the current humidity."""
return self._element.humidity

@property
def is_aux_heat_on(self):
"""Return true if aux heater."""
return self._element.mode == ThermostatMode.EMERGENCY_HEAT.value

@property
def target_temperature(self):
"""Return the temperature we try to reach."""
if (self._element.mode == ThermostatMode.HEAT.value) or (
self._element.mode == ThermostatMode.EMERGENCY_HEAT.value):
return self._element.heat_setpoint
if self._element.mode == ThermostatMode.COOL.value:
return self._element.cool_setpoint
return None

@property
def target_temperature_high(self):
"""Return the highbound target temperature we try to reach."""
return self._element.cool_setpoint

@property
def target_temperature_low(self):
"""Return the lowbound target temperature we try to reach."""
return self._element.heat_setpoint

@property
def min_temp(self):
"""Return the minimum temp supported."""
return 1

@property
def max_temp(self):
"""Return the maximum temp supported."""
return 99

@property
def current_operation(self):
"""Return current operation ie. heat, cool, idle."""
return self.state

@property
def operation_list(self):
"""Return the list of available operation modes."""
return [STATE_IDLE, STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_FAN_ONLY]

@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return 1

@property
def current_fan_mode(self):
"""Return the fan setting."""
if self._element.fan == ThermostatFan.AUTO.value:
return STATE_AUTO
if self._element.fan == ThermostatFan.ON.value:
return STATE_ON
return STATE_UNKNOWN

def set_operation_mode(self, operation_mode):
"""Set mode."""
if operation_mode == STATE_IDLE:
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.OFF.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)
elif operation_mode == STATE_HEAT:
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.HEAT.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)
elif operation_mode == STATE_COOL:
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.COOL.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)
elif operation_mode == STATE_AUTO:
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.AUTO.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)
elif operation_mode == STATE_FAN_ONLY:
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.OFF.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.ON.value)

def turn_aux_heat_on(self):
"""Turn auxiliary heater on."""
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.EMERGENCY_HEAT.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)

def turn_aux_heat_off(self):
"""Turn auxiliary heater off."""
self._element.set(ThermostatSetting.MODE.value,
ThermostatMode.HEAT.value)
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)

@property
def fan_list(self):
"""Return the list of available fan modes."""
return [STATE_AUTO, STATE_ON]

def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if fan_mode == STATE_AUTO:
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.AUTO.value)
elif fan_mode == STATE_ON:
self._element.set(ThermostatSetting.FAN.value,
ThermostatFan.ON.value)

def set_temperature(self, **kwargs):
"""Set new target temperature."""
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
if low_temp is not None:
low_temp = round(low_temp)
self._element.set(ThermostatSetting.HEAT_SETPOINT.value, low_temp)
if high_temp is not None:
high_temp = round(high_temp)
self._element.set(ThermostatSetting.COOL_SETPOINT.value, high_temp)
Loading