diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py new file mode 100644 index 00000000000..9a8f53c8bde --- /dev/null +++ b/homeassistant/components/axis/axis_base.py @@ -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) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index e9ef9f63710..86a2a738b70 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -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 diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 457cc23e73d..08e40f4999a 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -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)} - } diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 1595dde4cba..32c5ac090e9 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -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: diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 0379ee3b03c..507f63c12b5 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -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"] } diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py new file mode 100644 index 00000000000..852528120a5 --- /dev/null +++ b/homeassistant/components/axis/switch.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index ca262da4179..9b0eea4ce47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c932096825b..826544c4e8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 23714e51c88..ac2da3ddedc 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -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) diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py new file mode 100644 index 00000000000..1acb81ee0a2 --- /dev/null +++ b/tests/components/axis/test_switch.py @@ -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('\\')]