diff --git a/.coveragerc b/.coveragerc index 08cb51912c4..465e1f62315 100644 --- a/.coveragerc +++ b/.coveragerc @@ -16,6 +16,10 @@ omit = homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aftership/sensor.py + homeassistant/components/agent_dvr/__init__.py + homeassistant/components/agent_dvr/camera.py + homeassistant/components/agent_dvr/const.py + homeassistant/components/agent_dvr/helpers.py homeassistant/components/airly/__init__.py homeassistant/components/airly/air_quality.py homeassistant/components/airly/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index b8c18ad0258..d3c51d00a0e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,6 +15,7 @@ homeassistant/scripts/check_config.py @kellerza # Integrations homeassistant/components/abode/* @shred86 homeassistant/components/adguard/* @frenck +homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 diff --git a/homeassistant/components/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py new file mode 100644 index 00000000000..e11e61a4126 --- /dev/null +++ b/homeassistant/components/agent_dvr/__init__.py @@ -0,0 +1,82 @@ +"""Support for Agent.""" +import asyncio +import logging + +from agent import AgentError +from agent.a import Agent + +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONNECTION, DOMAIN as AGENT_DOMAIN, SERVER_URL + +ATTRIBUTION = "ispyconnect.com" +DEFAULT_BRAND = "Agent DVR by ispyconnect.com" + +_LOGGER = logging.getLogger(__name__) + +FORWARDS = ["camera"] + + +async def async_setup(hass, config): + """Old way to set up integrations.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Agent component.""" + hass.data.setdefault(AGENT_DOMAIN, {}) + + server_origin = config_entry.data[SERVER_URL] + + agent_client = Agent(server_origin, async_get_clientsession(hass)) + try: + await agent_client.update() + except AgentError: + await agent_client.close() + raise ConfigEntryNotReady + + if not agent_client.is_available: + raise ConfigEntryNotReady + + await agent_client.get_devices() + + hass.data[AGENT_DOMAIN][config_entry.entry_id] = {CONNECTION: agent_client} + + device_registry = await dr.async_get_registry(hass) + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(AGENT_DOMAIN, agent_client.unique)}, + manufacturer="iSpyConnect", + name=f"Agent {agent_client.name}", + model="Agent DVR", + sw_version=agent_client.version, + ) + + for forward in FORWARDS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, forward) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, forward) + for forward in FORWARDS + ] + ) + ) + + await hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION].close() + + if unload_ok: + hass.data[AGENT_DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py new file mode 100644 index 00000000000..ebc0eda222f --- /dev/null +++ b/homeassistant/components/agent_dvr/camera.py @@ -0,0 +1,215 @@ +"""Support for Agent camera streaming.""" +from datetime import timedelta +import logging + +from agent import AgentError + +from homeassistant.components.camera import SUPPORT_ON_OFF +from homeassistant.components.mjpeg.camera import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + MjpegCamera, + filter_urllib3_logging, +) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.helpers import entity_platform + +from .const import ( + ATTRIBUTION, + CAMERA_SCAN_INTERVAL_SECS, + CONNECTION, + DOMAIN as AGENT_DOMAIN, +) + +SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS) + +_LOGGER = logging.getLogger(__name__) + +_DEV_EN_ALT = "enable_alerts" +_DEV_DS_ALT = "disable_alerts" +_DEV_EN_REC = "start_recording" +_DEV_DS_REC = "stop_recording" +_DEV_SNAP = "snapshot" + +CAMERA_SERVICES = { + _DEV_EN_ALT: "async_enable_alerts", + _DEV_DS_ALT: "async_disable_alerts", + _DEV_EN_REC: "async_start_recording", + _DEV_DS_REC: "async_stop_recording", + _DEV_SNAP: "async_snapshot", +} + + +async def async_setup_entry( + hass, config_entry, async_add_entities, discovery_info=None +): + """Set up the Agent cameras.""" + filter_urllib3_logging() + cameras = [] + + server = hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION] + if not server.devices: + _LOGGER.warning("Could not fetch cameras from Agent server") + return + + for device in server.devices: + if device.typeID == 2: + camera = AgentCamera(device) + cameras.append(camera) + + async_add_entities(cameras) + + platform = entity_platform.current_platform.get() + for service, method in CAMERA_SERVICES.items(): + platform.async_register_entity_service(service, {}, method) + + +class AgentCamera(MjpegCamera): + """Representation of an Agent Device Stream.""" + + def __init__(self, device): + """Initialize as a subclass of MjpegCamera.""" + self._servername = device.client.name + self.server_url = device.client._server_url + + device_info = { + CONF_NAME: device.name, + CONF_MJPEG_URL: f"{self.server_url}{device.mjpeg_image_url}&size=640x480", + CONF_STILL_IMAGE_URL: f"{self.server_url}{device.still_image_url}&size=640x480", + } + self.device = device + self._removed = False + self._name = f"{self._servername} {device.name}" + self._unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" + super().__init__(device_info) + + @property + def device_info(self): + """Return the device info for adding the entity to the agent object.""" + return { + "identifiers": {(AGENT_DOMAIN, self._unique_id)}, + "name": self._name, + "manufacturer": "Agent", + "model": "Camera", + "sw_version": self.device.client.version, + } + + async def async_update(self): + """Update our state from the Agent API.""" + try: + await self.device.update() + if self._removed: + _LOGGER.debug("%s reacquired", self._name) + self._removed = False + except AgentError: + if self.device.client.is_available: # server still available - camera error + if not self._removed: + _LOGGER.error("%s lost", self._name) + self._removed = True + + @property + def device_state_attributes(self): + """Return the Agent DVR camera state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "editable": False, + "enabled": self.is_on, + "connected": self.connected, + "detected": self.is_detected, + "alerted": self.is_alerted, + "has_ptz": self.device.has_ptz, + "alerts_enabled": self.device.alerts_active, + } + + @property + def should_poll(self) -> bool: + """Update the state periodically.""" + return True + + @property + def is_recording(self) -> bool: + """Return whether the monitor is recording.""" + return self.device.recording + + @property + def is_alerted(self) -> bool: + """Return whether the monitor has alerted.""" + return self.device.alerted + + @property + def is_detected(self) -> bool: + """Return whether the monitor has alerted.""" + return self.device.detected + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.device.client.is_available + + @property + def connected(self) -> bool: + """Return True if entity is connected.""" + return self.device.connected + + @property + def supported_features(self) -> int: + """Return supported features.""" + return SUPPORT_ON_OFF + + @property + def is_on(self) -> bool: + """Return true if on.""" + return self.device.online + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self.is_on: + return "mdi:camcorder" + return "mdi:camcorder-off" + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self.device.detector_active + + @property + def unique_id(self) -> str: + """Return a unique identifier for this agent object.""" + return self._unique_id + + async def async_enable_alerts(self): + """Enable alerts.""" + await self.device.alerts_on() + + async def async_disable_alerts(self): + """Disable alerts.""" + await self.device.alerts_off() + + async def async_enable_motion_detection(self): + """Enable motion detection.""" + await self.device.detector_on() + + async def async_disable_motion_detection(self): + """Disable motion detection.""" + await self.device.detector_off() + + async def async_start_recording(self): + """Start recording.""" + await self.device.record() + + async def async_stop_recording(self): + """Stop recording.""" + await self.device.record_stop() + + async def async_turn_on(self): + """Enable the camera.""" + await self.device.enable() + + async def async_snapshot(self): + """Take a snapshot.""" + await self.device.snapshot() + + async def async_turn_off(self): + """Disable the camera.""" + await self.device.disable() diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py new file mode 100644 index 00000000000..a5c98ade1cb --- /dev/null +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow to configure Agent devices.""" +import logging + +from agent import AgentConnectionError, AgentError +from agent.a import Agent +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, SERVER_URL # pylint:disable=unused-import +from .helpers import generate_url + +DEFAULT_PORT = 8090 +_LOGGER = logging.getLogger(__name__) + + +class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an Agent config flow.""" + + def __init__(self): + """Initialize the Agent config flow.""" + self.device_config = {} + + async def async_step_user(self, info=None): + """Handle an Agent config flow.""" + errors = {} + + if info is not None: + host = info[CONF_HOST] + port = info[CONF_PORT] + + server_origin = generate_url(host, port) + agent_client = Agent(server_origin, async_get_clientsession(self.hass)) + + try: + await agent_client.update() + except AgentConnectionError: + pass + except AgentError: + pass + + await agent_client.close() + + if agent_client.is_available: + await self.async_set_unique_id(agent_client.unique) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: info[CONF_HOST], + CONF_PORT: info[CONF_PORT], + SERVER_URL: server_origin, + } + ) + + self.device_config = { + CONF_HOST: host, + CONF_PORT: port, + SERVER_URL: server_origin, + } + + return await self._create_entry(agent_client.name) + + errors["base"] = "device_unavailable" + + data = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + + return self.async_show_form( + step_id="user", + description_placeholders=self.device_config, + data_schema=vol.Schema(data), + errors=errors, + ) + + async def _create_entry(self, server_name): + """Create entry for device.""" + return self.async_create_entry(title=server_name, data=self.device_config) diff --git a/homeassistant/components/agent_dvr/const.py b/homeassistant/components/agent_dvr/const.py new file mode 100644 index 00000000000..e571edf9800 --- /dev/null +++ b/homeassistant/components/agent_dvr/const.py @@ -0,0 +1,11 @@ +"""Constants for agent_dvr component.""" +DOMAIN = "agent_dvr" +SERVERS = "servers" +DEVICES = "devices" +ENTITIES = "entities" +CAMERA_SCAN_INTERVAL_SECS = 5 +SERVICE_UPDATE = "update" +SIGNAL_UPDATE_AGENT = "agent_update" +ATTRIBUTION = "Data provided by ispyconnect.com" +SERVER_URL = "server_url" +CONNECTION = "connection" diff --git a/homeassistant/components/agent_dvr/helpers.py b/homeassistant/components/agent_dvr/helpers.py new file mode 100644 index 00000000000..028a683946d --- /dev/null +++ b/homeassistant/components/agent_dvr/helpers.py @@ -0,0 +1,13 @@ +"""Helpers for Agent DVR component.""" + + +def generate_url(host, port) -> str: + """Create a URL from the host and port.""" + server_origin = host + if "://" not in host: + server_origin = f"http://{host}" + + if server_origin[-1] == "/": + server_origin = server_origin[:-1] + + return f"{server_origin}:{port}/" diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json new file mode 100644 index 00000000000..1244326d494 --- /dev/null +++ b/homeassistant/components/agent_dvr/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "agent_dvr", + "name": "Agent DVR", + "documentation": "https://www.home-assistant.io/integrations/agent_dvr/", + "requirements": ["agent-py==0.0.20"], + "config_flow": true, + "codeowners": ["@ispysoftware"] +} diff --git a/homeassistant/components/agent_dvr/services.yaml b/homeassistant/components/agent_dvr/services.yaml new file mode 100644 index 00000000000..8bf1e01269a --- /dev/null +++ b/homeassistant/components/agent_dvr/services.yaml @@ -0,0 +1,34 @@ +start_recording: + description: Enable continuous recording. + fields: + entity_id: + description: "Name(s) of the entity to start recording." + example: "camera.camera_1" + +stop_recording: + description: Disable continuous recording. + fields: + entity_id: + description: "Name(s) of the entity to stop recording." + example: "camera.camera_1" + +enable_alerts: + description: Enable alerts + fields: + entity_id: + description: "Name(s) of the entity to enable alerts." + example: "camera.camera_1" + +disable_alerts: + description: Disable alerts + fields: + entity_id: + description: "Name(s) of the entity to disable alerts." + example: "camera.camera_1" + +snapshot: + description: Take a photo + fields: + entity_id: + description: "Name(s) of the entity to take a snapshot." + example: "camera.camera_1" diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json new file mode 100644 index 00000000000..ea62d9feb1f --- /dev/null +++ b/homeassistant/components/agent_dvr/strings.json @@ -0,0 +1,21 @@ +{ + "title": "Agent DVR", + "config": { + "step": { + "user": { + "title": "Set up Agent DVR", + "data": { + "host": "Host", + "port": "Port" + } + } + }, + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "already_in_progress": "Config flow for device is already in progress.", + "device_unavailable": "Device is not available" + } + } +} diff --git a/homeassistant/components/agent_dvr/translations/en.json b/homeassistant/components/agent_dvr/translations/en.json new file mode 100644 index 00000000000..ea62d9feb1f --- /dev/null +++ b/homeassistant/components/agent_dvr/translations/en.json @@ -0,0 +1,21 @@ +{ + "title": "Agent DVR", + "config": { + "step": { + "user": { + "title": "Set up Agent DVR", + "data": { + "host": "Host", + "port": "Port" + } + } + }, + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "already_in_progress": "Config flow for device is already in progress.", + "device_unavailable": "Device is not available" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 817109dfe95..23f70e89bf5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -8,6 +8,7 @@ To update, run python3 -m script.hassfest FLOWS = [ "abode", "adguard", + "agent_dvr", "airly", "airvisual", "almond", diff --git a/requirements_all.txt b/requirements_all.txt index a39ca08c576..e6392114e4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -128,6 +128,9 @@ adguardhome==0.4.2 # homeassistant.components.frontier_silicon afsapi==0.0.4 +# homeassistant.components.agent_dvr +agent-py==0.0.20 + # homeassistant.components.geonetnz_quakes aio_geojson_geonetnz_quakes==0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e942596e593..ae5e75e9d3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -38,6 +38,9 @@ adb-shell==0.1.3 # homeassistant.components.adguard adguardhome==0.4.2 +# homeassistant.components.agent_dvr +agent-py==0.0.20 + # homeassistant.components.geonetnz_quakes aio_geojson_geonetnz_quakes==0.12 diff --git a/tests/components/agent_dvr/__init__.py b/tests/components/agent_dvr/__init__.py new file mode 100644 index 00000000000..f0c059d12e2 --- /dev/null +++ b/tests/components/agent_dvr/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the agent_dvr component.""" + +from homeassistant.components.agent_dvr.const import DOMAIN, SERVER_URL +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def init_integration( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, +) -> MockConfigEntry: + """Set up the Agent DVR integration in Home Assistant.""" + + aioclient_mock.get( + "http://example.local:8090/command.cgi?cmd=getStatus", + text=load_fixture("agent_dvr/status.json"), + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.get( + "http://example.local:8090/command.cgi?cmd=getObjects", + text=load_fixture("agent_dvr/objects.json"), + headers={"Content-Type": "application/json"}, + ) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="c0715bba-c2d0-48ef-9e3e-bc81c9ea4447", + data={ + CONF_HOST: "example.local", + CONF_PORT: 8090, + SERVER_URL: "http://example.local:8090/", + }, + ) + + 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 diff --git a/tests/components/agent_dvr/test_config_flow.py b/tests/components/agent_dvr/test_config_flow.py new file mode 100644 index 00000000000..33b5805700f --- /dev/null +++ b/tests/components/agent_dvr/test_config_flow.py @@ -0,0 +1,90 @@ +"""Tests for the Agent DVR config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.agent_dvr import config_flow +from homeassistant.components.agent_dvr.const import SERVER_URL +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort flow if Agent device already configured.""" + await init_integration(hass, aioclient_mock) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.local", CONF_PORT: 8090}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_connection_error(hass: HomeAssistant, aioclient_mock) -> None: + """Test we show user form on Agent connection error.""" + + aioclient_mock.get("http://example.local:8090/command.cgi?cmd=getStatus", text="") + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.local", CONF_PORT: 8090}, + ) + + assert result["errors"] == {"base": "device_unavailable"} + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.get( + "http://example.local:8090/command.cgi?cmd=getStatus", + text=load_fixture("agent_dvr/status.json"), + headers={"Content-Type": "application/json"}, + ) + + aioclient_mock.get( + "http://example.local:8090/command.cgi?cmd=getObjects", + text=load_fixture("agent_dvr/objects.json"), + headers={"Content-Type": "application/json"}, + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "example.local", CONF_PORT: 8090} + ) + + assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_PORT] == 8090 + assert result["data"][SERVER_URL] == "http://example.local:8090/" + assert result["title"] == "DESKTOP" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entries = hass.config_entries.async_entries(config_flow.DOMAIN) + assert entries[0].unique_id == "c0715bba-c2d0-48ef-9e3e-bc81c9ea4447" diff --git a/tests/fixtures/agent_dvr/objects.json b/tests/fixtures/agent_dvr/objects.json new file mode 100644 index 00000000000..883679b47cb --- /dev/null +++ b/tests/fixtures/agent_dvr/objects.json @@ -0,0 +1 @@ +{"settings":{"canUpdate":true,"supportsPlugins":true,"isArmed":true,"background":"255,255,255"},"directories":[{"ID":0,"dir":"D:\\Projects\\agent-service\\AgentService\\Media\\WebServerRoot\\Media\\"}],"locations":[],"objectList": [],"profiles": [{"name":"Home","active":true,"id":0},{"name":"Away","active":false,"id":1},{"name":"Night","active":false,"id":2}],"views":[{"name":"0","mode":"column","objects":[],"maxWidth":1266,"maxHeight":1222,"backColor":"#222222","id":1,"typeID":2,"focused":false},{"name":"1","mode":"grid","objects":[]},{"name":"2","mode":"grid","objects":[]},{"name":"3","mode":"grid","objects":[]},{"name":"4","mode":"grid","objects":[]},{"name":"5","mode":"grid","objects":[]},{"name":"6","mode":"grid","objects":[]},{"name":"7","mode":"grid","objects":[]},{"name":"8","mode":"grid","objects":[]}],"rtmpStreaming":false} \ No newline at end of file diff --git a/tests/fixtures/agent_dvr/status.json b/tests/fixtures/agent_dvr/status.json new file mode 100644 index 00000000000..20c77b16bab --- /dev/null +++ b/tests/fixtures/agent_dvr/status.json @@ -0,0 +1,10 @@ +{ + "armed": false, + "devices": 4, + "active": 4, + "recording": 0, + "remoteAccess": true, + "unique": "c0715bba-c2d0-48ef-9e3e-bc81c9ea4447", + "name": "DESKTOP", + "version": "2.6.1.0" +}