diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index c9e8436fdeb..feae2c8fc99 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -42,7 +42,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add binary sensor from Axis device.""" event = device.api.event[event_id] - if event.CLASS != CLASS_OUTPUT: + if event.CLASS != CLASS_OUTPUT and not ( + event.CLASS == CLASS_LIGHT and event.TYPE == "Light" + ): async_add_entities([AxisBinarySensor(event, device)], True) device.listeners.append( diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index 68c04ef0d72..12a10391e4c 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -3,6 +3,7 @@ import logging from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN LOGGER = logging.getLogger(__package__) @@ -19,4 +20,4 @@ DEFAULT_EVENTS = True DEFAULT_STREAM_PROFILE = "No stream profile" DEFAULT_TRIGGER_TIME = 0 -PLATFORMS = [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN] diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py new file mode 100644 index 00000000000..75b2d59e5f5 --- /dev/null +++ b/homeassistant/components/axis/light.py @@ -0,0 +1,116 @@ +"""Support for Axis lights.""" + +from axis.event_stream import CLASS_LIGHT + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +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 light.""" + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] + + if not device.api.vapix.light_control: + return + + @callback + def async_add_sensor(event_id): + """Add light from Axis device.""" + event = device.api.event[event_id] + + if event.CLASS == CLASS_LIGHT and event.TYPE == "Light": + async_add_entities([AxisLight(event, device)], True) + + device.listeners.append( + async_dispatcher_connect(hass, device.signal_new_event, async_add_sensor) + ) + + +class AxisLight(AxisEventBase, LightEntity): + """Representation of a light Axis event.""" + + def __init__(self, event, device): + """Initialize the Axis light.""" + super().__init__(event, device) + + self.light_id = f"led{self.event.id}" + + self.current_intensity = 0 + self.max_intensity = 0 + + self._features = SUPPORT_BRIGHTNESS + + async def async_added_to_hass(self) -> None: + """Subscribe lights events.""" + await super().async_added_to_hass() + + def get_light_capabilities(): + """Get light capabilities.""" + current_intensity = self.device.api.vapix.light_control.get_current_intensity( + self.light_id + ) + self.current_intensity = current_intensity["data"]["intensity"] + + max_intensity = self.device.api.vapix.light_control.get_valid_intensity( + self.light_id + ) + self.max_intensity = max_intensity["data"]["ranges"][0]["high"] + + await self.hass.async_add_executor_job(get_light_capabilities) + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def name(self): + """Return the name of the light.""" + light_type = self.device.api.vapix.light_control[self.light_id].light_type + return f"{self.device.name} {light_type} {self.event.TYPE} {self.event.id}" + + @property + def is_on(self): + """Return true if light is on.""" + return self.event.is_tripped + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int((self.current_intensity / self.max_intensity) * 255) + + def turn_on(self, **kwargs): + """Turn on light.""" + if not self.is_on: + self.device.api.vapix.light_control.activate_light(self.light_id) + + if ATTR_BRIGHTNESS in kwargs: + intensity = int((kwargs[ATTR_BRIGHTNESS] / 255) * self.max_intensity) + self.device.api.vapix.light_control.set_manual_intensity( + self.light_id, intensity + ) + + def turn_off(self, **kwargs): + """Turn off light.""" + if self.is_on: + self.device.api.vapix.light_control.deactivate_light(self.light_id) + + def update(self): + """Update brightness.""" + current_intensity = self.device.api.vapix.light_control.get_current_intensity( + self.light_id + ) + self.current_intensity = current_intensity["data"]["intensity"] + + @property + def should_poll(self): + """Brightness needs polling.""" + return True diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index ea5b024e8fb..391f1f91a41 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/integrations/axis", - "requirements": ["axis==31"], + "requirements": ["axis==32"], "zeroconf": ["_axis-video._tcp.local."], "after_dependencies": ["mqtt"], "codeowners": ["@Kane610"] diff --git a/requirements_all.txt b/requirements_all.txt index b45b6c51bdf..90cf5f581a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -306,7 +306,7 @@ avea==1.4 avri-api==0.1.7 # homeassistant.components.axis -axis==31 +axis==32 # homeassistant.components.azure_event_hub azure-eventhub==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b76ba91bd7..24ac9114088 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -150,7 +150,7 @@ av==8.0.2 avri-api==0.1.7 # homeassistant.components.axis -axis==31 +axis==32 # homeassistant.components.homekit base36==0.1.1 diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 6fafdbae8cb..a4a40c18af1 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -7,6 +7,7 @@ import axis as axislib from axis.api_discovery import URL as API_DISCOVERY_URL from axis.basic_device_info import URL as BASIC_DEVICE_INFO_URL from axis.event_stream import OPERATION_INITIALIZED +from axis.light_control import URL as LIGHT_CONTROL_URL from axis.mqtt import URL_CLIENT as MQTT_CLIENT_URL from axis.param_cgi import ( BRAND as BRAND_URL, @@ -82,7 +83,6 @@ API_DISCOVERY_PORT_MANAGEMENT = { "name": "IO Port Management", } - BASIC_DEVICE_INFO_RESPONSE = { "apiVersion": "1.1", "data": { @@ -95,6 +95,27 @@ BASIC_DEVICE_INFO_RESPONSE = { }, } +LIGHT_CONTROL_RESPONSE = { + "apiVersion": "1.1", + "method": "getLightInformation", + "data": { + "items": [ + { + "lightID": "led0", + "lightType": "IR", + "enabled": True, + "synchronizeDayNightMode": True, + "lightState": False, + "automaticIntensityMode": False, + "automaticAngleOfIlluminationMode": False, + "nrOfLEDs": 1, + "error": False, + "errorInfo": "", + } + ] + }, +} + MQTT_CLIENT_RESPONSE = { "apiVersion": "1.0", "context": "some context", @@ -167,6 +188,8 @@ def vapix_session_request(session, url, **kwargs): return json.dumps(API_DISCOVERY_RESPONSE) if BASIC_DEVICE_INFO_URL in url: return json.dumps(BASIC_DEVICE_INFO_RESPONSE) + if LIGHT_CONTROL_URL in url: + return json.dumps(LIGHT_CONTROL_RESPONSE) if MQTT_CLIENT_URL in url: return json.dumps(MQTT_CLIENT_RESPONSE) if PORT_MANAGEMENT_URL in url: @@ -217,10 +240,11 @@ async def test_device_setup(hass): entry = device.config_entry - assert len(forward_entry_setup.mock_calls) == 3 + assert len(forward_entry_setup.mock_calls) == 4 assert forward_entry_setup.mock_calls[0][1] == (entry, "binary_sensor") assert forward_entry_setup.mock_calls[1][1] == (entry, "camera") - assert forward_entry_setup.mock_calls[2][1] == (entry, "switch") + assert forward_entry_setup.mock_calls[2][1] == (entry, "light") + assert forward_entry_setup.mock_calls[3][1] == (entry, "switch") assert device.host == ENTRY_CONFIG[CONF_HOST] assert device.model == ENTRY_CONFIG[CONF_MODEL] diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py new file mode 100644 index 00000000000..98613451b0d --- /dev/null +++ b/tests/components/axis/test_light.py @@ -0,0 +1,150 @@ +"""Axis light platform tests.""" + +from copy import deepcopy + +from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.setup import async_setup_component + +from .test_device import API_DISCOVERY_RESPONSE, NAME, setup_axis_integration + +from tests.async_mock import patch + +API_DISCOVERY_LIGHT_CONTROL = { + "id": "light-control", + "version": "1.1", + "name": "Light Control", +} + +EVENT_ON = { + "operation": "Initialized", + "topic": "tns1:Device/tnsaxis:Light/Status", + "source": "id", + "source_idx": "0", + "type": "state", + "value": "ON", +} + +EVENT_OFF = { + "operation": "Initialized", + "topic": "tns1:Device/tnsaxis:Light/Status", + "source": "id", + "source_idx": "0", + "type": "state", + "value": "OFF", +} + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when platform is manually configured.""" + assert await async_setup_component( + hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": AXIS_DOMAIN}} + ) + + assert AXIS_DOMAIN not in hass.data + + +async def test_no_lights(hass): + """Test that no light events in Axis results in no light entities.""" + await setup_axis_integration(hass) + + assert not hass.states.async_entity_ids(LIGHT_DOMAIN) + + +async def test_lights(hass): + """Test that lights are loaded properly.""" + api_discovery = deepcopy(API_DISCOVERY_RESPONSE) + api_discovery["data"]["apiList"].append(API_DISCOVERY_LIGHT_CONTROL) + + with patch.dict(API_DISCOVERY_RESPONSE, api_discovery): + device = await setup_axis_integration(hass) + + # Add light + with patch( + "axis.light_control.LightControl.get_current_intensity", + return_value={"data": {"intensity": 100}}, + ), patch( + "axis.light_control.LightControl.get_valid_intensity", + return_value={"data": {"ranges": [{"high": 150}]}}, + ): + device.api.event.process_event(EVENT_ON) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_0 = hass.states.get(f"light.{NAME}_ir_light_0") + assert light_0.state == "on" + assert light_0.name == f"{NAME} IR Light 0" + + # Turn on, set brightness, light already on + with patch( + "axis.light_control.LightControl.activate_light" + ) as mock_activate, patch( + "axis.light_control.LightControl.set_manual_intensity" + ) as mock_set_intensity, patch( + "axis.light_control.LightControl.get_current_intensity", + return_value={"data": {"intensity": 100}}, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": f"light.{NAME}_ir_light_0", ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + mock_activate.not_called() + mock_set_intensity.assert_called_once_with("led0", 29) + + # Turn off + with patch( + "axis.light_control.LightControl.deactivate_light" + ) as mock_deactivate, patch( + "axis.light_control.LightControl.get_current_intensity", + return_value={"data": {"intensity": 100}}, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + {"entity_id": f"light.{NAME}_ir_light_0"}, + blocking=True, + ) + mock_deactivate.assert_called_once() + + # Event turn off light + device.api.event.process_event(EVENT_OFF) + await hass.async_block_till_done() + + light_0 = hass.states.get(f"light.{NAME}_ir_light_0") + assert light_0.state == "off" + + # Turn on, set brightness + with patch( + "axis.light_control.LightControl.activate_light" + ) as mock_activate, patch( + "axis.light_control.LightControl.set_manual_intensity" + ) as mock_set_intensity, patch( + "axis.light_control.LightControl.get_current_intensity", + return_value={"data": {"intensity": 100}}, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": f"light.{NAME}_ir_light_0"}, + blocking=True, + ) + mock_activate.assert_called_once() + mock_set_intensity.assert_not_called() + + # Turn off, light already off + with patch( + "axis.light_control.LightControl.deactivate_light" + ) as mock_deactivate, patch( + "axis.light_control.LightControl.get_current_intensity", + return_value={"data": {"intensity": 100}}, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + {"entity_id": f"light.{NAME}_ir_light_0"}, + blocking=True, + ) + mock_deactivate.assert_not_called()