Skip to content

Commit

Permalink
renew auth token after device reboot and handling picture http except…
Browse files Browse the repository at this point in the history
…ions
  • Loading branch information
maciej-or committed Nov 25, 2024
1 parent 99f7be3 commit 7341d14
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 65 deletions.
87 changes: 43 additions & 44 deletions custom_components/hikvision_next/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import asyncio
from datetime import timedelta
import logging

Expand Down Expand Up @@ -35,22 +34,11 @@ def __init__(self, hass: HomeAssistant, device) -> None:

async def _async_update_data(self):
"""Update data via ISAPI."""
async with asyncio.timeout(30):
data = {}

# Get camera event status
for camera in self.device.cameras:
for event in camera.events_info:
if event.disabled:
continue
try:
_id = ENTITY_ID_FORMAT.format(event.unique_id)
data[_id] = await self.device.get_event_enabled_state(event)
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {event.id}")

# Get NVR event status
for event in self.device.events_info:
data = {}

# Get camera event status
for camera in self.device.cameras:
for event in camera.events_info:
if event.disabled:
continue
try:
Expand All @@ -59,23 +47,35 @@ async def _async_update_data(self):
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {event.id}")

# Get output port(s) status
for i in range(1, self.device.capabilities.output_ports + 1):
try:
_id = ENTITY_ID_FORMAT.format(
f"{slugify(self.device.device_info.serial_no.lower())}_{i}_alarm_output"
)
data[_id] = await self.device.get_io_port_status("output", i)
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for alarm output {i}")
# Get NVR event status
for event in self.device.events_info:
if event.disabled:
continue
try:
_id = ENTITY_ID_FORMAT.format(event.unique_id)
data[_id] = await self.device.get_event_enabled_state(event)
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {event.id}")

# Refresh HDD data
# Get output port(s) status
for i in range(1, self.device.capabilities.output_ports + 1):
try:
self.device.storage = await self.device.get_storage_devices()
_id = ENTITY_ID_FORMAT.format(f"{slugify(self.device.device_info.serial_no.lower())}_{i}_alarm_output")
data[_id] = await self.device.get_io_port_status("output", i)
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, "Cannot fetch storage state")
self.device.handle_exception(ex, f"Cannot fetch state for alarm output {i}")

# Refresh HDD data
try:
self.device.storage = await self.device.get_storage_devices()
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, "Cannot fetch storage state")

return data

if self.device.auth_token_expired:
self.device.auth_token_expired = False

return data


class SecondaryCoordinator(DataUpdateCoordinator):
Expand All @@ -94,17 +94,16 @@ def __init__(self, hass: HomeAssistant, device) -> None:

async def _async_update_data(self):
"""Update data via ISAPI."""
async with asyncio.timeout(20):
data = {}
try:
if self.device.capabilities.support_holiday_mode:
data[HOLIDAY_MODE] = await self.device.get_holiday_enabled_state()
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {HOLIDAY_MODE}")
try:
if self.device.capabilities.support_alarm_server:
alarm_server = await self.device.get_alarm_server()
data[CONF_ALARM_SERVER_HOST] = alarm_server
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {CONF_ALARM_SERVER_HOST}")
return data
data = {}
try:
if self.device.capabilities.support_holiday_mode:
data[HOLIDAY_MODE] = await self.device.get_holiday_enabled_state()
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {HOLIDAY_MODE}")
try:
if self.device.capabilities.support_alarm_server:
alarm_server = await self.device.get_alarm_server()
data[CONF_ALARM_SERVER_HOST] = alarm_server
except Exception as ex: # pylint: disable=broad-except
self.device.handle_exception(ex, f"Cannot fetch state for {CONF_ALARM_SERVER_HOST}")
return data
32 changes: 21 additions & 11 deletions custom_components/hikvision_next/hikvision_device.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"ISAPI client for Home Assistant integration."

import asyncio
import logging
from typing import Any

Expand All @@ -9,20 +8,19 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util import slugify

from .const import (
RTSP_PORT_FORCED,
ALARM_SERVER_PATH,
CONF_ALARM_SERVER_HOST,
CONF_SET_ALARM_SERVER,
DOMAIN,
EVENTS,
EVENTS_COORDINATOR,
RTSP_PORT_FORCED,
SECONDARY_COORDINATOR,
)
from .coordinator import EventsCoordinator, SecondaryCoordinator
Expand Down Expand Up @@ -52,17 +50,18 @@ def __init__(
config = entry.data if entry else data
self.entry = entry
self.hass = hass
self.auth_token_expired = False
self.control_alarm_server_host = config[CONF_SET_ALARM_SERVER]
self.alarm_server_host = config[CONF_ALARM_SERVER_HOST]

# init ISAPI client
host = config[CONF_HOST]
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
varify_ssl = config.get(CONF_VERIFY_SSL, True)
verify_ssl = config.get(CONF_VERIFY_SSL, True)
rtsp_port_forced = config.get(RTSP_PORT_FORCED, None)
session = get_async_client(hass, varify_ssl)
super().__init__(host, username, password, rtsp_port_forced, session)
session = get_async_client(hass, verify_ssl)
super().__init__(host, username, password, verify_ssl, rtsp_port_forced, session)

self.events_info: list[EventInfo] = []

Expand Down Expand Up @@ -146,13 +145,24 @@ def get_device_event_capabilities(
def handle_exception(self, ex: Exception, details: str = ""):
"""Handle common exceptions."""

host = self.host
error = "Unexpected exception"

if isinstance(ex, ISAPIUnauthorizedError):
if not self.auth_token_expired:
# after device reboot, authorization token may have expired
self.auth_token_expired = True
self._auth_method = None
self._session = get_async_client(self.hass, self.verify_ssl)
_LOGGER.warning("Unauthorized access to %s, started checking if token expired", self.host)
return
self.auth_token_expired = False
self.entry.async_start_reauth(self.hass)
error = "Unauthorized access"
elif isinstance(ex, ISAPIForbiddenError):
raise HomeAssistantError(f"{ex.message} {details}")
elif isinstance(ex, (asyncio.TimeoutError, httpx.TimeoutException)):
raise HomeAssistantError(f"Timeout while connecting to {host} {details}")
error = "Forbidden access"
elif isinstance(ex, (httpx.TimeoutException, httpx.ConnectTimeout)):
error = "Timeout"
elif isinstance(ex, (httpx.ConnectError, httpx.NetworkError)):
error = "Connection error"

_LOGGER.warning("Unexpected exception | %s | %s", details, ex)
_LOGGER.warning("%s | %s | %s | %s", error, self.host, details, ex)
28 changes: 18 additions & 10 deletions custom_components/hikvision_next/isapi/isapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from contextlib import suppress
import datetime
from http import HTTPStatus
import ipaddress
import json
import logging
from typing import Any, AsyncIterator
Expand All @@ -13,7 +14,6 @@
import httpx
from httpx import HTTPStatusError
import xmltodict
import ipaddress

from .const import (
CONNECTION_TYPE_DIRECT,
Expand Down Expand Up @@ -57,6 +57,7 @@ def __init__(
host: str,
username: str,
password: str,
verify_ssl: bool = True,
rtsp_port_forced: int = None,
session: httpx.AsyncClient = None,
) -> None:
Expand All @@ -65,12 +66,13 @@ def __init__(
self.host = host
self.username = username
self.password = password
self.verify_ssl = verify_ssl
self.timeout = 20
self.isapi_prefix = "ISAPI"
self._session = session
self._auth_method: httpx._auth.Auth = None

self.rtsp_port_forced=rtsp_port_forced
self.rtsp_port_forced = rtsp_port_forced

self.device_info = ISAPIDeviceInfo()
self.capabilities = CapabilitiesInfo()
Expand Down Expand Up @@ -711,7 +713,7 @@ def get_stream_source(self, stream: CameraStreamInfo) -> str:
async def _detect_auth_method(self):
"""Establish the connection with device."""
if not self._session:
self._session = httpx.AsyncClient(timeout=self.timeout)
self._session = httpx.AsyncClient(timeout=self.timeout, verify=self.verify_ssl)

url = urljoin(self.host, self.isapi_prefix + "/System/deviceInfo")
_LOGGER.debug("--- [WWW-Authenticate detection] %s", self.host)
Expand All @@ -728,9 +730,9 @@ async def _detect_auth_method(self):
_LOGGER.error("Authentication method not detected, %s", response.status_code)
if response.headers:
_LOGGER.error("response.headers %s", response.headers)
response.raise_for_status()

def get_isapi_url(self, relative_url: str) -> str:
"""Build full ISAPI URL."""
return f"{self.host}/{self.isapi_prefix}/{relative_url}"

async def request(
Expand All @@ -740,7 +742,7 @@ async def request(
present: str = "dict",
data: str = None,
) -> Any:
"""Send request and log response, returns {} if request fails."""
"""Send ISAPI request and log response, returns {} if request fails."""
full_url = self.get_isapi_url(url)
try:
if not self._auth_method:
Expand Down Expand Up @@ -778,12 +780,17 @@ async def request_bytes(
full_url: str,
**data,
) -> AsyncIterator[bytes]:
if not self._auth_method:
await self._detect_auth_method()
"""Send ISAPI request for binary data."""

try:
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
async with self._session.stream(method, full_url, auth=self._auth_method, **data) as response:
async for chunk in response.aiter_bytes():
yield chunk
except httpx.HTTPError as ex:
_LOGGER.warning("Failed request [%s] %s | %s", method, full_url, ex)


class ISAPISetEventStateMutexError(Exception):
Expand Down Expand Up @@ -814,3 +821,4 @@ def __init__(self, ex: HTTPStatusError, *args) -> None:
"""Initialize exception."""
self.message = f"Forbidden request {ex.request.url}, check user permissions."
self.response = ex.response
_LOGGER.warning(self.message)

0 comments on commit 7341d14

Please sign in to comment.