Skip to content

Commit

Permalink
api client with non blocking calls (#215)
Browse files Browse the repository at this point in the history
* api client with non blocking calls

* pytest-cov version fix
  • Loading branch information
maciej-or authored Oct 14, 2024
1 parent e6710ce commit 1d72649
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 31 deletions.
4 changes: 3 additions & 1 deletion custom_components/hikvision_next/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.httpx_client import get_async_client

from .const import (
ALARM_SERVER_PATH,
Expand Down Expand Up @@ -49,7 +50,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
host = entry.data[CONF_HOST]
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
isapi = ISAPI(host, username, password)
session = get_async_client(hass)
isapi = ISAPI(host, username, password, session)
isapi.pending_initialization = True
try:
await isapi.get_hardware_info()
Expand Down
67 changes: 40 additions & 27 deletions custom_components/hikvision_next/isapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@
import datetime
from functools import reduce
from http import HTTPStatus
import httpx
import json
import logging
from typing import Any, Optional
from urllib.parse import quote, urlparse

from hikvisionapi import AsyncClient
from httpx import HTTPStatusError, TimeoutException
import xmltodict

from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.util import slugify
from .isapi_client import ISAPI_Client

from .const import (
CONNECTION_TYPE_DIRECT,
Expand All @@ -39,9 +40,9 @@

_LOGGER = logging.getLogger(__name__)

GET = "get"
PUT = "put"
POST = "post"
GET = "GET"
PUT = "PUT"
POST = "POST"


@dataclass
Expand All @@ -67,6 +68,7 @@ class AlertInfo:
region_id: int = 0
detection_target: Optional[str] = None


@dataclass
class MutexIssue:
"""Holds mutually exclusive event checking info."""
Expand Down Expand Up @@ -179,9 +181,15 @@ class IPCamera(AnalogCamera):
class ISAPI:
"""hikvisionapi async client wrapper."""

def __init__(self, host: str, username: str, password: str) -> None:
def __init__(
self,
host: str,
username: str,
password: str,
session: Optional[httpx.AsyncClient] = None,
) -> None:
"""Initialize."""
self.isapi = AsyncClient(host, username, password, timeout=20)
self.isapi = ISAPI_Client(host, username, password, session, timeout=20)
self.host = host
self.device_info = HikDeviceInfo()
self.cameras: list[IPCamera | AnalogCamera] = []
Expand Down Expand Up @@ -388,7 +396,7 @@ async def get_device_event_capabilities(
io_port_id=event.io_port_id,
unique_id=unique_id,
url=self.get_event_url(event, connection_type),
disabled=("center" not in event.notifications), # Disable if not set Notify Surveillance Center
disabled=("center" not in event.notifications), # Disable if not set Notify Surveillance Center
)
events.append(event_info)
return events
Expand Down Expand Up @@ -685,13 +693,7 @@ async def set_port_state(self, port_no: int, turn_on: bool):
data["IOPortData"] = {"outputState": "low"}

xml = xmltodict.unparse(data)
response = await self.isapi.System.IO.outputs[port_no].trigger(method=PUT, data=xml)
_LOGGER.debug(
"[PUT] %s/ISAPI/System/IO/outputs/%s/trigger %s",
self.isapi.host,
port_no,
response,
)
await self.request(PUT, f"System/IO/outputs/{port_no}/trigger", present="xml", data=xml)

async def get_holiday_enabled_state(self, holiday_index=0) -> bool:
"""Get holiday state."""
Expand Down Expand Up @@ -779,15 +781,15 @@ async def request(
) -> Any:
"""Send request and log response, returns {} if request fails."""

full_url = f"{self.isapi.host}/{self.isapi.isapi_prefix}/{url}"
full_url = self.isapi.get_url(url)
try:
response = await self.isapi.common_request(method, full_url, present, self.isapi.timeout, **data)
_LOGGER.debug("--- [%s] %s", method.upper(), full_url)
response = await self.isapi.request(method, full_url, present, **data)
_LOGGER.debug("--- [%s] %s", method, full_url)
if data:
_LOGGER.debug(">>> payload:\n%s", data)
_LOGGER.debug("\n%s", response)
except HTTPStatusError as ex:
_LOGGER.info("--- [%s] %s\n%s", method.upper(), full_url, ex)
_LOGGER.info("--- [%s] %s\n%s", method, full_url, ex)
if self.pending_initialization:
# supress http errors during initialization
return {}
Expand All @@ -808,10 +810,11 @@ def is_reauth_needed():
host = self.isapi.host
if is_reauth_needed():
# Re-establish session
self.isapi = AsyncClient(
self.isapi.host,
self.isapi.login,
self.isapi = ISAPI_Client(
host,
self.isapi.username,
self.isapi.password,
self.isapi.session,
timeout=20,
)
return True
Expand Down Expand Up @@ -855,7 +858,15 @@ def parse_event_notification(xml: str) -> AlertInfo:
if not EVENTS[event_id]:
raise ValueError(f"Unsupported event {event_id}")

return AlertInfo(channel_id, io_port_id, event_id, device_serial, mac, region_id, detection_target)
return AlertInfo(
channel_id,
io_port_id,
event_id,
device_serial,
mac,
region_id,
detection_target,
)

async def get_camera_image(
self,
Expand All @@ -873,11 +884,13 @@ async def get_camera_image(
}

if stream.use_alternate_picture_url:
chunks = self.isapi.ContentMgmt.StreamingProxy.channels[stream.id].picture(
method=GET, type="opaque_data", params=params
)
url = f"ContentMgmt/StreamingProxy/channels/{stream.id}/picture"
full_url = self.isapi.get_url(url)
chunks = self.isapi.request_bytes(GET, full_url, params=params)
else:
chunks = self.isapi.Streaming.channels[stream.id].picture(method=GET, type="opaque_data", params=params)
url = f"Streaming/channels/{stream.id}/picture"
full_url = self.isapi.get_url(url)
chunks = self.isapi.request_bytes(GET, full_url, params=params)
data = b"".join([chunk async for chunk in chunks])

if data.startswith(b"<?xml "):
Expand All @@ -895,7 +908,7 @@ async def get_camera_image(

def get_stream_source(self, stream: CameraStreamInfo) -> str:
"""Get stream source."""
u = quote(self.isapi.login, safe="")
u = quote(self.isapi.username, safe="")
p = quote(self.isapi.password, safe="")
url = f"{self.device_info.ip_address}:{self.device_info.rtsp_port}/Streaming/channels/{stream.id}"
return f"rtsp://{u}:{p}@{url}"
Expand Down
86 changes: 86 additions & 0 deletions custom_components/hikvision_next/isapi_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import httpx
from typing import Any, AsyncIterator, List, Union
from urllib.parse import urljoin
import json
import xmltodict
from dataclasses import dataclass


def response_parser(response, present="dict"):
"""Parse Hikvision results."""
if isinstance(response, (list,)):
result = "".join(response)
elif isinstance(response, str):
result = response
else:
result = response.text

if present is None or present == "dict":
if isinstance(response, (list,)):
events = []
for event in response:
e = json.loads(json.dumps(xmltodict.parse(event)))
events.append(e)
return events
return json.loads(json.dumps(xmltodict.parse(result)))
else:
return result


@dataclass
class ISAPI_Client:
host: str
username: str
password: str
session: httpx.AsyncClient | None = None
timeout: float = 3
isapi_prefix: str = "ISAPI"
_auth_method: httpx._auth.Auth = None

async def _detect_auth_method(self):
"""Establish the connection with device."""
if not self.session:
self.session = httpx.AsyncClient(timeout=self.timeout)

url = urljoin(self.host, self.isapi_prefix + "/System/status")
for method in [
httpx.BasicAuth(self.username, self.password),
httpx.DigestAuth(self.username, self.password),
]:
response = await self.session.get(url, auth=method)
if response.status_code == 200:
self._auth_method = method

if not self._auth_method:
response.raise_for_status()

def get_url(self, relative_url: str) -> str:
return f"{self.host}/{self.isapi_prefix}/{relative_url}"

async def request(
self,
method: str,
full_url: str,
present: str = "dict",
data: dict[str, Any] | None = None,
) -> Union[List[str], str]:
"""Send request to the device."""
if not self._auth_method:
await self._detect_auth_method()

response = await self.session.request(method, full_url, auth=self._auth_method, data=data, timeout=self.timeout)
response.raise_for_status()
return response_parser(response, present)

async def request_bytes(
self,
method: str,
full_url: str,
**data,
) -> AsyncIterator[bytes]:
if not self._auth_method:
await self._detect_auth_method()

async with self.session.stream(method, full_url, auth=self._auth_method, **data) as response:
async for chunk in response.aiter_bytes():
yield chunk
1 change: 0 additions & 1 deletion custom_components/hikvision_next/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"issue_tracker": "https://github.com/maciej-or/hikvision_next/issues",
"requirements": [
"xmltodict==0.13.0",
"hikvisionapi==0.3.2",
"requests-toolbelt==1.0.0"
],
"version": "1.0.17"
Expand Down
3 changes: 1 addition & 2 deletions requirements.test.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# manifest.json
xmltodict==0.13.0
hikvisionapi==0.3.2
requests-toolbelt==1.0.0

# tests
pytest
pytest-asyncio
pytest-cov
pytest-cov>=4.1.0
pytest-homeassistant-custom-component

#
Expand Down

0 comments on commit 1d72649

Please sign in to comment.