Add agent_dvr integration (#32711)

* initial

* add missing fixture

* fix mocks

* fix mocks 2

* update coverage

* fix broken sync between agent and integration

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* updates for review

* add back in should poll again

* revert motion detection enabled flag in state attributes

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/agent_dvr/camera.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* add is_streaming

* fix is_streaming bug, remove mp4 stream

* cleanup

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
sean tearney 2020-05-08 22:49:47 +08:00 committed by GitHub
parent 3a0d5126ae
commit 1be41b9de8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 641 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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"

View File

@ -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}/"

View File

@ -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"]
}

View File

@ -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"

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -8,6 +8,7 @@ To update, run python3 -m script.hassfest
FLOWS = [
"abode",
"adguard",
"agent_dvr",
"airly",
"airvisual",
"almond",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

1
tests/fixtures/agent_dvr/objects.json vendored Normal file
View File

@ -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}

10
tests/fixtures/agent_dvr/status.json vendored Normal file
View File

@ -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"
}