Axis IO-port support (#23312)

Support digital inputs and supervised inputs, digital outputs and relays
This commit is contained in:
Robert Svensson 2019-05-20 07:45:31 +02:00 committed by GitHub
parent 5c346e8fb6
commit eb912be47a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 334 additions and 99 deletions

View File

@ -0,0 +1,86 @@
"""Base classes for Axis entities."""
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DOMAIN as AXIS_DOMAIN
class AxisEntityBase(Entity):
"""Base common to all Axis entities."""
def __init__(self, device):
"""Initialize the Axis event."""
self.device = device
self.unsub_dispatcher = []
async def async_added_to_hass(self):
"""Subscribe device events."""
self.unsub_dispatcher.append(async_dispatcher_connect(
self.hass, self.device.event_reachable, self.update_callback))
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe device events when removed."""
for unsub_dispatcher in self.unsub_dispatcher:
unsub_dispatcher()
@property
def available(self):
"""Return True if device is available."""
return self.device.available
@property
def device_info(self):
"""Return a device description for device registry."""
return {
'identifiers': {(AXIS_DOMAIN, self.device.serial)}
}
@callback
def update_callback(self, no_delay=None):
"""Update the entities state."""
self.async_schedule_update_ha_state()
class AxisEventBase(AxisEntityBase):
"""Base common to all Axis entities from event stream."""
def __init__(self, event, device):
"""Initialize the Axis event."""
super().__init__(device)
self.event = event
async def async_added_to_hass(self) -> None:
"""Subscribe sensors events."""
self.event.register_callback(self.update_callback)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed."""
self.event.remove_callback(self.update_callback)
await super().async_will_remove_from_hass()
@property
def device_class(self):
"""Return the class of the event."""
return self.event.CLASS
@property
def name(self):
"""Return the name of the event."""
return '{} {} {}'.format(
self.device.name, self.event.TYPE, self.event.id)
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def unique_id(self):
"""Return a unique identifier for this device."""
return '{}-{}-{}'.format(
self.device.serial, self.event.topic, self.event.id)

View File

@ -2,6 +2,8 @@
from datetime import timedelta
from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME
from homeassistant.core import callback
@ -9,7 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
from .const import DOMAIN as AXIS_DOMAIN, LOGGER
from .axis_base import AxisEventBase
from .const import DOMAIN as AXIS_DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
@ -21,32 +24,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
def async_add_sensor(event_id):
"""Add binary sensor from Axis device."""
event = device.api.event.events[event_id]
async_add_entities([AxisBinarySensor(event, device)], True)
if event.CLASS != CLASS_OUTPUT:
async_add_entities([AxisBinarySensor(event, device)], True)
device.listeners.append(async_dispatcher_connect(
hass, device.event_new_sensor, async_add_sensor))
class AxisBinarySensor(BinarySensorDevice):
class AxisBinarySensor(AxisEventBase, BinarySensorDevice):
"""Representation of a binary Axis event."""
def __init__(self, event, device):
"""Initialize the Axis binary sensor."""
self.event = event
self.device = device
super().__init__(event, device)
self.remove_timer = None
self.unsub_dispatcher = None
async def async_added_to_hass(self):
"""Subscribe sensors events."""
self.event.register_callback(self.update_callback)
self.unsub_dispatcher = async_dispatcher_connect(
self.hass, self.device.event_reachable, self.update_callback)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed."""
self.event.remove_callback(self.update_callback)
self.unsub_dispatcher()
@callback
def update_callback(self, no_delay=False):
@ -67,7 +59,6 @@ class AxisBinarySensor(BinarySensorDevice):
@callback
def _delay_update(now):
"""Timer callback for sensor update."""
LOGGER.debug("%s called delayed (%s sec) update", self.name, delay)
self.async_schedule_update_ha_state()
self.remove_timer = None
@ -83,32 +74,10 @@ class AxisBinarySensor(BinarySensorDevice):
@property
def name(self):
"""Return the name of the event."""
return '{} {} {}'.format(
self.device.name, self.event.TYPE, self.event.id)
if self.event.CLASS == CLASS_INPUT and self.event.id and \
self.device.api.vapix.ports[self.event.id].name:
return '{} {}'.format(
self.device.name,
self.device.api.vapix.ports[self.event.id].name)
@property
def device_class(self):
"""Return the class of the event."""
return self.event.CLASS
@property
def unique_id(self):
"""Return a unique identifier for this device."""
return '{}-{}-{}'.format(
self.device.serial, self.event.topic, self.event.id)
def available(self):
"""Return True if device is available."""
return self.device.available
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def device_info(self):
"""Return a device description for device registry."""
return {
'identifiers': {(AXIS_DOMAIN, self.device.serial)}
}
return super().name

View File

@ -6,9 +6,9 @@ from homeassistant.components.mjpeg.camera import (
from homeassistant.const import (
CONF_AUTHENTICATION, CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME,
CONF_PASSWORD, CONF_PORT, CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .axis_base import AxisEntityBase
from .const import DOMAIN as AXIS_DOMAIN
AXIS_IMAGE = 'http://{}:{}/axis-cgi/jpg/image.cgi'
@ -38,28 +38,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities([AxisCamera(config, device)])
class AxisCamera(MjpegCamera):
class AxisCamera(AxisEntityBase, MjpegCamera):
"""Representation of a Axis camera."""
def __init__(self, config, device):
"""Initialize Axis Communications camera component."""
super().__init__(config)
self.device_config = config
self.device = device
self.port = device.config_entry.data[CONF_DEVICE][CONF_PORT]
self.unsub_dispatcher = []
AxisEntityBase.__init__(self, device)
MjpegCamera.__init__(self, config)
async def async_added_to_hass(self):
"""Subscribe camera events."""
self.unsub_dispatcher.append(async_dispatcher_connect(
self.hass, self.device.event_new_address, self._new_address))
self.unsub_dispatcher.append(async_dispatcher_connect(
self.hass, self.device.event_reachable, self.update_callback))
async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed."""
for unsub_dispatcher in self.unsub_dispatcher:
unsub_dispatcher()
await super().async_added_to_hass()
@property
def supported_features(self):
@ -74,29 +66,13 @@ class AxisCamera(MjpegCamera):
self.device.config_entry.data[CONF_DEVICE][CONF_PASSWORD],
self.device.host)
@callback
def update_callback(self, no_delay=None):
"""Update the cameras state."""
self.async_schedule_update_ha_state()
@property
def available(self):
"""Return True if device is available."""
return self.device.available
def _new_address(self):
"""Set new device address for video stream."""
self._mjpeg_url = AXIS_VIDEO.format(self.device.host, self.port)
self._still_image_url = AXIS_IMAGE.format(self.device.host, self.port)
port = self.device.config_entry.data[CONF_DEVICE][CONF_PORT]
self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port)
self._still_image_url = AXIS_IMAGE.format(self.device.host, port)
@property
def unique_id(self):
"""Return a unique identifier for this device."""
return '{}-camera'.format(self.device.serial)
@property
def device_info(self):
"""Return a device description for device registry."""
return {
'identifiers': {(AXIS_DOMAIN, self.device.serial)}
}

View File

@ -83,19 +83,23 @@ class AxisNetworkDevice:
self.product_type = self.api.vapix.params.prodtype
if self.config_entry.options[CONF_CAMERA]:
self.hass.async_create_task(
self.hass.config_entries.async_forward_entry_setup(
self.config_entry, 'camera'))
if self.config_entry.options[CONF_EVENTS]:
task = self.hass.async_create_task(
self.hass.config_entries.async_forward_entry_setup(
self.config_entry, 'binary_sensor'))
self.api.stream.connection_status_callback = \
self.async_connection_status_callback
self.api.enable_events(event_callback=self.async_event_callback)
task.add_done_callback(self.start)
platform_tasks = [
self.hass.config_entries.async_forward_entry_setup(
self.config_entry, platform)
for platform in ['binary_sensor', 'switch']
]
self.hass.async_create_task(self.start(platform_tasks))
self.config_entry.add_update_listener(self.async_new_address_callback)
@ -145,9 +149,9 @@ class AxisNetworkDevice:
if action == 'add':
async_dispatcher_send(self.hass, self.event_new_sensor, event_id)
@callback
def start(self, fut):
"""Start the event stream."""
async def start(self, platform_tasks):
"""Start the event stream when all platforms are loaded."""
await asyncio.gather(*platform_tasks)
self.api.start()
@callback
@ -157,15 +161,22 @@ class AxisNetworkDevice:
async def async_reset(self):
"""Reset this device to default state."""
self.api.stop()
platform_tasks = []
if self.config_entry.options[CONF_CAMERA]:
await self.hass.config_entries.async_forward_entry_unload(
self.config_entry, 'camera')
platform_tasks.append(
self.hass.config_entries.async_forward_entry_unload(
self.config_entry, 'camera'))
if self.config_entry.options[CONF_EVENTS]:
await self.hass.config_entries.async_forward_entry_unload(
self.config_entry, 'binary_sensor')
self.api.stop()
platform_tasks += [
self.hass.config_entries.async_forward_entry_unload(
self.config_entry, platform)
for platform in ['binary_sensor', 'switch']
]
await asyncio.gather(*platform_tasks)
for unsub_dispatcher in self.listeners:
unsub_dispatcher()
@ -185,13 +196,22 @@ async def get_device(hass, config):
port=config[CONF_PORT], web_proto='http')
device.vapix.initialize_params(preload_data=False)
device.vapix.initialize_ports()
try:
with async_timeout.timeout(15):
await hass.async_add_executor_job(
device.vapix.params.update_brand)
await hass.async_add_executor_job(
device.vapix.params.update_properties)
await asyncio.gather(
hass.async_add_executor_job(
device.vapix.params.update_brand),
hass.async_add_executor_job(
device.vapix.params.update_properties),
hass.async_add_executor_job(
device.vapix.ports.update)
)
return device
except axis.Unauthorized:

View File

@ -3,7 +3,7 @@
"name": "Axis",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/axis",
"requirements": ["axis==22"],
"requirements": ["axis==23"],
"dependencies": [],
"codeowners": ["@kane610"]
}

View File

@ -0,0 +1,59 @@
"""Support for Axis switches."""
from axis.event_stream import CLASS_OUTPUT
from homeassistant.components.switch import SwitchDevice
from homeassistant.const import CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .axis_base import AxisEventBase
from .const import DOMAIN as AXIS_DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Axis switch."""
serial_number = config_entry.data[CONF_MAC]
device = hass.data[AXIS_DOMAIN][serial_number]
@callback
def async_add_switch(event_id):
"""Add switch from Axis device."""
event = device.api.event.events[event_id]
if event.CLASS == CLASS_OUTPUT:
async_add_entities([AxisSwitch(event, device)], True)
device.listeners.append(async_dispatcher_connect(
hass, device.event_new_sensor, async_add_switch))
class AxisSwitch(AxisEventBase, SwitchDevice):
"""Representation of a Axis switch."""
@property
def is_on(self):
"""Return true if event is active."""
return self.event.is_tripped
async def async_turn_on(self, **kwargs):
"""Turn on switch."""
action = '/'
await self.hass.async_add_executor_job(
self.device.api.vapix.ports[self.event.id].action, action)
async def async_turn_off(self, **kwargs):
"""Turn off switch."""
action = '\\'
await self.hass.async_add_executor_job(
self.device.api.vapix.ports[self.event.id].action, action)
@property
def name(self):
"""Return the name of the event."""
if self.event.id and self.device.api.vapix.ports[self.event.id].name:
return '{} {}'.format(
self.device.name,
self.device.api.vapix.ports[self.event.id].name)
return super().name

View File

@ -207,7 +207,7 @@ av==6.1.2
# avion==0.10
# homeassistant.components.axis
axis==22
axis==23
# homeassistant.components.baidu
baidu-aip==1.6.6

View File

@ -67,7 +67,7 @@ apns2==0.3.0
av==6.1.2
# homeassistant.components.axis
axis==22
axis==23
# homeassistant.components.zha
bellows-homeassistant==0.7.3

View File

@ -37,6 +37,7 @@ async def test_device_setup():
api = Mock()
axis_device = device.AxisNetworkDevice(hass, entry)
axis_device.start = Mock()
assert axis_device.host == DEVICE_DATA[device.CONF_HOST]
assert axis_device.model == ENTRY_CONFIG[device.CONF_MODEL]
@ -47,11 +48,13 @@ async def test_device_setup():
assert await axis_device.async_setup() is True
assert axis_device.api is api
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 2
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
(entry, 'camera')
assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \
(entry, 'binary_sensor')
assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \
(entry, 'switch')
async def test_device_signal_new_address(hass):
@ -71,7 +74,7 @@ async def test_device_signal_new_address(hass):
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
assert len(axis_device.listeners) == 1
assert len(axis_device.listeners) == 2
entry.data[device.CONF_DEVICE][device.CONF_HOST] = '2.3.4.5'
hass.config_entries.async_update_entry(entry, data=entry.data)
@ -193,6 +196,8 @@ async def test_get_device(hass):
with patch('axis.param_cgi.Params.update_brand',
return_value=mock_coro()), \
patch('axis.param_cgi.Params.update_properties',
return_value=mock_coro()), \
patch('axis.port_cgi.Ports.update',
return_value=mock_coro()):
assert await device.get_device(hass, DEVICE_DATA)

View File

@ -0,0 +1,120 @@
"""Axis switch platform tests."""
from unittest.mock import call as mock_call, Mock
from homeassistant import config_entries
from homeassistant.components import axis
from homeassistant.setup import async_setup_component
import homeassistant.components.switch as switch
EVENTS = [
{
'operation': 'Initialized',
'topic': 'tns1:Device/Trigger/Relay',
'source': 'RelayToken',
'source_idx': '0',
'type': 'LogicalState',
'value': 'inactive'
},
{
'operation': 'Initialized',
'topic': 'tns1:Device/Trigger/Relay',
'source': 'RelayToken',
'source_idx': '1',
'type': 'LogicalState',
'value': 'active'
}
]
ENTRY_CONFIG = {
axis.CONF_DEVICE: {
axis.config_flow.CONF_HOST: '1.2.3.4',
axis.config_flow.CONF_USERNAME: 'user',
axis.config_flow.CONF_PASSWORD: 'pass',
axis.config_flow.CONF_PORT: 80
},
axis.config_flow.CONF_MAC: '1234ABCD',
axis.config_flow.CONF_MODEL: 'model',
axis.config_flow.CONF_NAME: 'model 0'
}
ENTRY_OPTIONS = {
axis.CONF_CAMERA: False,
axis.CONF_EVENTS: True,
axis.CONF_TRIGGER_TIME: 0
}
async def setup_device(hass):
"""Load the Axis switch platform."""
from axis import AxisDevice
loop = Mock()
config_entry = config_entries.ConfigEntry(
1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS)
device = axis.AxisNetworkDevice(hass, config_entry)
device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE])
hass.data[axis.DOMAIN] = {device.serial: device}
device.api.enable_events(event_callback=device.async_event_callback)
await hass.config_entries.async_forward_entry_setup(
config_entry, 'switch')
# To flush out the service call to update the group
await hass.async_block_till_done()
return device
async def test_platform_manually_configured(hass):
"""Test that nothing happens when platform is manually configured."""
assert await async_setup_component(hass, switch.DOMAIN, {
'switch': {
'platform': axis.DOMAIN
}
})
assert axis.DOMAIN not in hass.data
async def test_no_switches(hass):
"""Test that no output events in Axis results in no switch entities."""
await setup_device(hass)
assert not hass.states.async_entity_ids('switch')
async def test_switches(hass):
"""Test that switches are loaded properly."""
device = await setup_device(hass)
device.api.vapix.ports = {'0': Mock(), '1': Mock()}
device.api.vapix.ports['0'].name = 'Doorbell'
device.api.vapix.ports['1'].name = ''
for event in EVENTS:
device.api.stream.event.manage_event(event)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3
relay_0 = hass.states.get('switch.model_0_doorbell')
assert relay_0.state == 'off'
assert relay_0.name == 'model 0 Doorbell'
relay_1 = hass.states.get('switch.model_0_relay_1')
assert relay_1.state == 'on'
assert relay_1.name == 'model 0 Relay 1'
device.api.vapix.ports['0'].action = Mock()
await hass.services.async_call('switch', 'turn_on', {
'entity_id': 'switch.model_0_doorbell'
}, blocking=True)
await hass.services.async_call('switch', 'turn_off', {
'entity_id': 'switch.model_0_doorbell'
}, blocking=True)
assert device.api.vapix.ports['0'].action.call_args_list == \
[mock_call('/'), mock_call('\\')]