diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a2ba46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +/.idea +.vscode/ + +venv/ + +__test__.py \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8944a69 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,93 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.7.2 + hooks: + - id: pyupgrade + args: [--py38-plus] + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v2.0.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + exclude: ^tests/fixtures/ + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.1.1 + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.7.0 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 5.5.3 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + exclude: (.vscode|.devcontainer) + - id: no-commit-to-branch + args: + - --branch=dev + - --branch=master + - --branch=rc + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.24.2 + hooks: + - id: yamllint + - repo: https://github.com/prettier/prettier + rev: 2.0.4 + hooks: + - id: prettier + stages: [manual] + - repo: local + hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: mypy + entry: script/run-in-env.sh mypy + language: script + types: [python] + require_serial: true + files: ^homeassistant/.+\.py$ + - id: gen_requirements_all + name: gen_requirements_all + entry: script/run-in-env.sh python3 -m script.gen_requirements_all + pass_filenames: false + language: script + types: [text] + files: ^(homeassistant/.+/manifest\.json|\.pre-commit-config\.yaml)$ + - id: hassfest + name: hassfest + entry: script/run-in-env.sh python3 -m script.hassfest + pass_filenames: false + language: script + types: [text] + files: ^(homeassistant/.+/(manifest|strings)\.json|\.coveragerc)$ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e4b88a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2021-04-14 + +Initial release \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a072188 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Shinobi Video NVR + +## Description + +Integration with Shinobi Video NVR. Creates the following components: + +* Camera - per-camera defined. +* Binary Sensors (MOTION, SOUND) - per-camera defined. +* Support HLS Streams instead of H264. +* Support SSL with self-signed certificate. + +[Changelog](https://github.com/elad-bar/ha-shinobi/blob/master/CHANGELOG.md) + +## How to + +#### Requirements +- Shinobi Video Server available with credentials +- MQTT Integration is optional - it will allow to listen to BlueIris event + +#### Shinobi links: +- [Using MQTT to receive and trigger events](https://hub.shinobi.video/articles/view/xEMps3O4y4VEaYk) +- [How to use Motion Detection](https://hub.shinobi.video/articles/view/LKdcgcgWy9RJfUh) + + +#### Installations via HACS +Currently, repository is not under official HACS repo, in order to install, you will need to add manually the repository + +Look for "Shinobi Video NVR" and install + +#### Integration settings +###### Basic configuration (Configuration -> Integrations -> Add BlueIris) +Fields name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +Host | Texbox | + | None | Hostname or IP address of the BlueIris server +Port | Textbox | + | 0 | HTTP Port to access BlueIris server +SSL | Check-box | + | Unchecked | Is SSL supported? +Username | Textbox | - | | Username of admin user for BlueIris server +Password | Textbox | - | | Password of admin user for BlueIris server + +###### Integration options (Configuration -> Integrations -> BlueIris Integration -> Options) +Fields name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +Host | Texbox | + | ast stored hostname | Hostname or IP address of the BlueIris server +Port | Textbox | + | 0ast stored port | HTTP Port to access BlueIris server +SSL | Check-box | + | Last stored SSL flag | Is SSL supported? +Username | Textbox | - | Last stored username | Username of admin user for BlueIris server +Password | Textbox | - | Last stored password | Password of admin user for BlueIris server +Log level | Drop-down | + | Default | Changes component's log level (more details below) + +**Log Level's drop-down** +New feature to set the log level for the component without need to set log_level in `customization:` and restart or call manually `logger.set_level` and loose it after restart. + +Upon startup or integration's option update, based on the value chosen, the component will make a service call to `logger.set_level` for that component with the desired value, + +In case `Default` option is chosen, flow will skip calling the service, after changing from any other option to `Default`, it will not take place automatically, only after restart + +###### Configuration validations +Upon submitting the form of creating an integration or updating options, + +Component will try to log in into the Shinobi Video server to verify new settings, following errors can appear: +- Integration already configured with the same title +- Invalid server details - Cannot reach the server + +###### Encryption key got corrupted +If a persistent notification popped up with the following message: +``` +Encryption key got corrupted, please remove the integration and re-add it +``` + +It means that encryption key was modified from outside the code, +Please remove the integration and re-add it to make it work again. + +## Components + +#### Binary Sensors +Binary sensor are relying on MQTT, you will need to set up in Shinobi Video Server MQTT plugin and configure each of the monitors to trigger MQTT message. + +Each binary sensor will have the name pattern - {Integration Title} {Camera Name} {Sound / Motion}, +Once triggered, the following details will be added to the attributes of the binary sensor: + +Attributes | Description | +--- | --- | +name | Event name - Yolo / audio +reason | Event details - object / soundChange +tags | relevant for motion only with object detection, will represent the detected object + + +###### Audio +Represents whether the camera is triggered for noise or not + +###### Motion +Represents whether the camera is triggered for motion or not + +###### Camera +State: Idle + +Attributes | Available values | +--- | --- | +Status | Recording, +Mode | stop (Disabled), start (Watch-Only), record (Record) +Type | H264, MJPEG, +FPS | - diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/shinobi/__init__.py b/custom_components/shinobi/__init__.py new file mode 100644 index 0000000..365cbea --- /dev/null +++ b/custom_components/shinobi/__init__.py @@ -0,0 +1,66 @@ +""" +This component provides support for Shinobi Video. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/shinobi/ +""" +import logging +import sys + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .helpers import async_set_ha, clear_ha, get_ha, handle_log_level +from .helpers.const import * + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Shinobi Video component.""" + initialized = False + + try: + await handle_log_level(hass, entry) + + _LOGGER.debug(f"Starting async_setup_entry of {DOMAIN}") + entry.add_update_listener(async_options_updated) + + await async_set_ha(hass, entry) + + initialized = True + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to load Shinobi Video, error: {ex}, line: {line_number}") + + return initialized + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + ha = get_ha(hass, entry.entry_id) + + if ha is not None: + await ha.async_remove(entry) + + clear_ha(hass, entry.entry_id) + + return True + + +async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry): + """Triggered by config entry options updates.""" + await handle_log_level(hass, entry) + + _LOGGER.info(f"async_options_updated, Entry: {entry.as_dict()} ") + + ha = get_ha(hass, entry.entry_id) + + if ha is not None: + await ha.async_update_entry(entry) diff --git a/custom_components/shinobi/api/__init__.py b/custom_components/shinobi/api/__init__.py new file mode 100644 index 0000000..57bf195 --- /dev/null +++ b/custom_components/shinobi/api/__init__.py @@ -0,0 +1,10 @@ + +class APIRequestException(Exception): + endpoint: str + response: dict + + def __init__(self, endpoint, response): + super().__init__(f"API Request failed") + + self.endpoint = endpoint + self.response = response diff --git a/custom_components/shinobi/api/shinobi_api.py b/custom_components/shinobi/api/shinobi_api.py new file mode 100644 index 0000000..c32853e --- /dev/null +++ b/custom_components/shinobi/api/shinobi_api.py @@ -0,0 +1,231 @@ +from datetime import datetime +import json +import logging +import sys +from typing import List, Optional + +import aiohttp +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from . import APIRequestException +from ..managers.configuration_manager import ConfigManager +from ..models.camera_data import CameraData + +from ..helpers.const import * + +REQUIREMENTS = ["aiohttp"] + +_LOGGER = logging.getLogger(__name__) + + +class ShinobiApi: + """The Class for handling the data retrieval.""" + + is_logged_in: bool + group_id: Optional[str] + api_key: Optional[str] + session: Optional[ClientSession] + camera_list: List[CameraData] + hass: HomeAssistant + config_manager: ConfigManager + base_url: Optional[str] + + def __init__(self, hass: HomeAssistant, config_manager: ConfigManager): + try: + self._last_update = datetime.now() + self.hass = hass + self.config_manager = config_manager + self.session_id = None + self.is_logged_in = False + self.group_id = None + self.api_key = None + self.session = None + self.is_logged_in = False + self.base_url = None + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to load Shinobi Video API, error: {ex}, line: {line_number}" + ) + + @property + def is_initialized(self): + return self.session is not None and not self.session.closed + + @property + def config_data(self): + return self.config_manager.data + + async def initialize(self): + _LOGGER.info("Initializing BlueIris") + + try: + config_data = self.config_data + + self.base_url = ( + f"{config_data.protocol}://{config_data.host}:{config_data.port}" + ) + + self.is_logged_in = False + self.camera_list = [] + + if self.hass is None: + if self.session is not None: + await self.session.close() + + self.session = aiohttp.client.ClientSession() + else: + self.session = async_create_clientsession(hass=self.hass) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to initialize BlueIris API ({self.base_url}), error: {ex}, line: {line_number}" + ) + + def build_url(self, endpoint): + url = f"{self.base_url}/{endpoint}" + + if GROUP_ID in url: + url = url.replace(GROUP_ID, self.group_id) + + if AUTH_TOKEN in url: + url = url.replace(AUTH_TOKEN, self.api_key) + + return url + + async def async_post(self, endpoint, request_data=None): + result = None + url = self.build_url(endpoint) + + try: + _LOGGER.debug(f"POST {url}") + + async with self.session.post(url, data=request_data, ssl=False) as response: + _LOGGER.debug(f"Status of {url}: {response.status}") + + response.raise_for_status() + + result = await response.json() + + self._last_update = datetime.now() + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to post data to {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + async def async_get(self, endpoint): + result = None + url = self.build_url(endpoint) + + try: + _LOGGER.debug(f"GET {url}") + + async with self.session.get(url, ssl=False) as response: + _LOGGER.debug(f"Status of {url}: {response.status}") + + response.raise_for_status() + + result = await response.json() + + self._last_update = datetime.now() + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to get data from {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + async def async_update(self): + _LOGGER.info(f"Updating data from Shinobi Video Server ({self.config_data.name})") + + await self.load_camera() + + async def login(self): + _LOGGER.info("Performing login") + + try: + self.api_key = None + + config_data = self.config_manager.data + + data = { + LOGIN_USERNAME: config_data.username, + LOGIN_PASSWORD: config_data.password_clear_text + } + + login_data = await self.async_post(URL_LOGIN, data) + user_data = login_data.get("$user") + + if user_data.get("ok", False): + self.group_id = user_data.get("ke") + self.api_key = user_data.get("auth_token") + uid = user_data.get("uid") + + _LOGGER.debug(f"Auth token: {self.api_key}") + + api_keys_data = await self.async_get(URL_API_KEYS) + + if api_keys_data.get("ok", False): + keys = api_keys_data.get("keys", []) + + for key in keys: + key_uid = key.get("uid") + + if key_uid == uid: + self.api_key = key.get("code") + + _LOGGER.debug(f"Access token: {self.api_key}") + + break + else: + self.api_key = None + raise APIRequestException(URL_API_KEYS, login_data) + + else: + raise APIRequestException(URL_LOGIN, login_data) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to login, Error: {ex}, Line: {line_number}") + + self.is_logged_in = self.api_key is not None + + return self.is_logged_in + + async def load_camera(self): + _LOGGER.debug("Retrieving camera list") + + camera_list = [] + monitors = await self.async_get(URL_MONITORS) + + for monitor in monitors: + monitor_details_str = monitor.get("details") + details = json.loads(monitor_details_str) + + monitor["details"] = details + + camera = CameraData(monitor) + camera_list.append(camera) + + self.camera_list = camera_list diff --git a/custom_components/shinobi/binary_sensor.py b/custom_components/shinobi/binary_sensor.py new file mode 100644 index 0000000..9621f27 --- /dev/null +++ b/custom_components/shinobi/binary_sensor.py @@ -0,0 +1,136 @@ +""" +Support for Shinobi Video binary sensors. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.shinobi/ +""" +import logging +from datetime import datetime + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later + +from .helpers.const import * +from .managers.mqtt_manager import MQTTManager +from .models.base_entity import async_setup_base_entry, BaseEntity +from .models.entity_data import EntityData + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [DOMAIN, "mqtt"] + +CURRENT_DOMAIN = DOMAIN_BINARY_SENSOR + + +def get_binary_sensor(hass: HomeAssistant, host: str, entity: EntityData): + binary_sensor = BaseBinarySensor() + binary_sensor.initialize(hass, host, entity, CURRENT_DOMAIN) + + return binary_sensor + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Shinobi Video Binary Sensor.""" + await async_setup_base_entry( + hass, config_entry, async_add_devices, CURRENT_DOMAIN, get_binary_sensor + ) + + +async def async_unload_entry(hass, config_entry): + _LOGGER.info(f"async_unload_entry {CURRENT_DOMAIN}: {config_entry}") + + return True + + +class BaseBinarySensor(BinarySensorEntity, BaseEntity): + """Representation a binary sensor that is updated by MQTT.""" + + def __init__(self): + super().__init__() + + self._last_alert = None + + @property + def mqtt_manager(self) -> MQTTManager: + """Force update.""" + return self.entity_manager.mqtt_manager + + @property + def topic(self): + """Return the polling state.""" + return self.entity.topic + + @property + def event_type(self): + """Return the polling state.""" + return self.entity.event + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.entity.state == STATE_ON + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.entity.device_class + + @property + def force_update(self): + """Force update.""" + return DEFAULT_FORCE_UPDATE + + @property + def event_duration(self): + return TRIGGER_DURATION.get(self.event_type, 20) + + async def async_added_to_hass_local(self): + _LOGGER.info(f"Added new {self.name}") + + def _immediate_update(self, previous_state: bool): + is_on = self.entity.state == STATE_ON + was_changed = self.state != previous_state + + if was_changed: + _LOGGER.debug( + f"{self.name} updated from {previous_state} to {self.state}" + ) + + def turn_off_automatically(now): + mqtt_state = self.mqtt_manager.get_state(self.topic, self.event_type) + + last_alert = mqtt_state.get(TRIGGER_TIMESTAMP) + action_timestamp = datetime.now().timestamp() + + if last_alert is None: + diff = self.event_duration + else: + diff = action_timestamp - last_alert + + if diff >= self.event_duration: + timeline = f"Started: {last_alert}, Ended: {action_timestamp}" + _LOGGER.info(f"Turn off {self.name} after {diff} seconds, {timeline}") + + self.mqtt_manager.set_state(self.topic, self.event_type, TRIGGER_DEFAULT) + self.ha.mqtt_event_handler() + + if is_on: + trigger_state = self.mqtt_manager.get_state(self.topic, self.event_type) + trigger_alert = trigger_state.get(TRIGGER_TIMESTAMP) + + _LOGGER.debug(f"{self.name} triggered at {trigger_alert}") + + async_call_later(self.hass, self.event_duration, turn_off_automatically) + + if was_changed: + _LOGGER.info(f"First {self.event_type} event to turn on {self.name}") + + super()._immediate_update(previous_state) + + else: + super()._immediate_update(previous_state) diff --git a/custom_components/shinobi/camera.py b/custom_components/shinobi/camera.py new file mode 100644 index 0000000..522c164 --- /dev/null +++ b/custom_components/shinobi/camera.py @@ -0,0 +1,143 @@ +""" +Support for Shinobi Video. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.shinobi/ +""" +from abc import ABC +import asyncio +import logging + +import aiohttp +import async_timeout + +from homeassistant.components.camera import SUPPORT_STREAM, Camera +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .helpers.const import * +from .models.base_entity import BaseEntity, async_setup_base_entry +from .models.entity_data import EntityData + +DEPENDENCIES = [DOMAIN] + +_LOGGER = logging.getLogger(__name__) + +CURRENT_DOMAIN = DOMAIN_CAMERA + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Shinobi Video Camera.""" + await async_setup_base_entry( + hass, config_entry, async_add_devices, CURRENT_DOMAIN, get_camera + ) + + +async def async_unload_entry(hass, config_entry): + _LOGGER.info(f"async_unload_entry {CURRENT_DOMAIN}: {config_entry}") + + return True + + +def get_camera(hass: HomeAssistant, host: str, entity: EntityData): + device_info = entity.details + + camera = ShinobiCamera(hass, device_info) + camera.initialize(hass, host, entity, CURRENT_DOMAIN) + + return camera + + +class ShinobiCamera(Camera, BaseEntity, ABC): + """ Shinobi Video Camera """ + + def __init__(self, hass, device_info): + super().__init__() + self.hass = hass + + stream_source = device_info.get(CONF_STREAM_SOURCE) + stream_support = device_info.get(CONF_SUPPORT_STREAM, False) + + stream_support_flag = 0 + + if stream_source and stream_support: + stream_support_flag = SUPPORT_STREAM + + self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._still_image_url.hass = hass + + self._stream_source = device_info[CONF_STREAM_SOURCE] + self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + self._frame_interval = 1 / device_info[CONF_FRAMERATE] + self._supported_features = stream_support_flag + self.content_type = device_info[CONF_CONTENT_TYPE] + self.verify_ssl = device_info[CONF_VERIFY_SSL] + + username = device_info.get(CONF_USERNAME) + password = device_info.get(CONF_PASSWORD) + + if username and password: + self._auth = aiohttp.BasicAuth(username, password=password) + else: + self._auth = None + + self._last_url = None + self._last_image = None + + def _immediate_update(self, previous_state: bool): + if previous_state != self.entity.state: + _LOGGER.debug( + f"{self.name} updated from {previous_state} to {self.entity.state}" + ) + + super()._immediate_update(previous_state) + + async def async_added_to_hass_local(self): + """Subscribe MQTT events.""" + _LOGGER.info(f"Added new {self.name}") + + @property + def supported_features(self): + """Return supported features for this camera.""" + return self._supported_features + + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return self._frame_interval + + def camera_image(self): + """Return bytes of camera image.""" + return asyncio.run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop + ).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + try: + url = self._still_image_url.async_render() + except TemplateError as err: + _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) + return self._last_image + + if url == self._last_url and self._limit_refetch: + return self._last_image + + try: + websession = async_get_clientsession(self.hass, verify_ssl=self.verify_ssl) + with async_timeout.timeout(10): + response = await websession.get(url, auth=self._auth) + self._last_image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image from %s", self.name) + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image from %s: %s", self.name, err) + return self._last_image + + self._last_url = url + return self._last_image + + async def stream_source(self): + """Return the source of the stream.""" + return self._stream_source diff --git a/custom_components/shinobi/config_flow.py b/custom_components/shinobi/config_flow.py new file mode 100644 index 0000000..db96401 --- /dev/null +++ b/custom_components/shinobi/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow to configure Shinobi Video.""" +import logging + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback + +from .helpers.const import * +from .managers.config_flow_manager import ConfigFlowManager +from .models import AlreadyExistsError, LoginError + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class DomainFlowHandler(config_entries.ConfigFlow): + """Handle a domain config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + super().__init__() + + self._config_flow = ConfigFlowManager() + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return DomainOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + _LOGGER.debug(f"Starting async_step_user of {DEFAULT_NAME}") + + errors = None + + await self._config_flow.initialize(self.hass) + + new_user_input = self._config_flow.clone_items(user_input) + + if user_input is not None: + try: + data = await self._config_flow.update_data(user_input, CONFIG_FLOW_DATA) + + _LOGGER.debug("Completed") + + return self.async_create_entry(title=self._config_flow.title, data=data) + except LoginError as lex: + del new_user_input[CONF_PASSWORD] + + _LOGGER.warning("Cannot complete login") + + errors = lex.errors + + except AlreadyExistsError as aeex: + _LOGGER.warning( + f"{DEFAULT_NAME} with {ENTRY_PRIMARY_KEY}: {aeex.title} already exists" + ) + + errors = {"base": "already_configured"} + + schema = await self._config_flow.get_default_data(new_user_input) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + description_placeholders=new_user_input, + ) + + async def async_step_import(self, info): + """Import existing configuration.""" + _LOGGER.debug(f"Starting async_step_import of {DEFAULT_NAME}") + + title = f"{DEFAULT_NAME} (import from configuration.yaml)" + + return self.async_create_entry(title=title, data=info) + + +class DomainOptionsFlowHandler(config_entries.OptionsFlow): + """Handle domain options.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize domain options flow.""" + super().__init__() + + self._config_entry = config_entry + + self._config_flow = ConfigFlowManager() + + async def async_step_init(self, user_input=None): + """Manage the domain options.""" + return await self.async_step_shinobi_additional_settings(user_input) + + async def async_step_shinobi_additional_settings(self, user_input=None): + _LOGGER.info(f"Starting additional settings step: {user_input}") + errors = None + + await self._config_flow.initialize(self.hass, self._config_entry) + + if user_input is not None: + try: + options = await self._config_flow.update_options( + user_input, CONFIG_FLOW_OPTIONS + ) + + return self.async_create_entry( + title=self._config_flow.title, data=options + ) + except LoginError as lex: + del user_input[CONF_PASSWORD] + + _LOGGER.warning("Cannot complete login") + + errors = lex.errors + + except AlreadyExistsError as aeex: + _LOGGER.warning( + f"{DEFAULT_NAME} with {ENTRY_PRIMARY_KEY}: {aeex.title} already exists" + ) + + errors = {"base": "already_configured"} + + schema = self._config_flow.get_default_options() + + return self.async_show_form( + step_id="shinobi_additional_settings", + data_schema=schema, + errors=errors, + description_placeholders=user_input, + ) diff --git a/custom_components/shinobi/helpers/__init__.py b/custom_components/shinobi/helpers/__init__.py new file mode 100644 index 0000000..82aaefa --- /dev/null +++ b/custom_components/shinobi/helpers/__init__.py @@ -0,0 +1,58 @@ +import logging +import sys + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from ..managers.home_assistant import HomeAssistantManager +from ..managers.password_manager import PasswordManager +from .const import * + +_LOGGER = logging.getLogger(__name__) + + +def clear_ha(hass: HomeAssistant, entry_id): + if DATA not in hass.data: + hass.data[DATA] = dict() + + del hass.data[DATA][entry_id] + + +def get_ha(hass: HomeAssistant, entry_id): + ha_data = hass.data.get(DATA, dict()) + ha = ha_data.get(entry_id) + + return ha + + +async def async_set_ha(hass: HomeAssistant, entry: ConfigEntry): + try: + if DATA not in hass.data: + hass.data[DATA] = dict() + + if PASSWORD_MANAGER not in hass.data: + hass.data[PASSWORD_MANAGER] = PasswordManager(hass) + + password_manager = hass.data[PASSWORD_MANAGER] + + instance = HomeAssistantManager(hass, password_manager) + + await instance.async_init(entry) + + hass.data[DATA][entry.entry_id] = instance + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to async_set_ha, error: {ex}, line: {line_number}") + + +async def handle_log_level(hass: HomeAssistant, entry: ConfigEntry): + log_level = entry.options.get(CONF_LOG_LEVEL, LOG_LEVEL_DEFAULT) + + if log_level == LOG_LEVEL_DEFAULT: + return + + log_level_data = {f"custom_components.{DOMAIN}": log_level.lower()} + + await hass.services.async_call(DOMAIN_LOGGER, SERVICE_SET_LEVEL, log_level_data) diff --git a/custom_components/shinobi/helpers/const.py b/custom_components/shinobi/helpers/const.py new file mode 100644 index 0000000..6affcc8 --- /dev/null +++ b/custom_components/shinobi/helpers/const.py @@ -0,0 +1,230 @@ +""" +Support for Shinobi Video. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.shinobi/ +""" +from datetime import timedelta + +from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR +from homeassistant.components.camera import DOMAIN as DOMAIN_CAMERA +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH +from homeassistant.components.mqtt import DATA_MQTT + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + STATE_ON, + STATE_OFF +) + +CONF_LOG_LEVEL = "log_level" + +CONF_SUPPORT_STREAM = "support_stream" + +CONF_ARR = [CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_SSL] + +ENTRY_PRIMARY_KEY = CONF_NAME + +CONFIG_FLOW_DATA = "config_flow_data" +CONFIG_FLOW_OPTIONS = "config_flow_options" +CONFIG_FLOW_INIT = "config_flow_init" + +VERSION = "1.0.0" + +DOMAIN = "shinobi" +PASSWORD_MANAGER = f"pm_{DOMAIN}" +DATA = f"data_{DOMAIN}" +DATA_API = f"{DATA}_API" +DATA_HA = f"{DATA}_HA" +DATA_HA_ENTITIES = f"{DATA}_HA_Entities" +DEFAULT_NAME = "Shinobi Video" +DEFAULT_PORT = 8080 + +DOMAIN_KEY_FILE = f"{DOMAIN}.key" +JSON_DATA_FILE = f"custom_components/{DOMAIN}/data/[NAME].json" + +DOMAIN_LOGGER = "logger" +SERVICE_SET_LEVEL = "set_level" + +SHINOBI_AUTH_ERROR = "Authorization required" + +AUTHENTICATION_BASIC = "basic" + +NOTIFICATION_ID = f"{DOMAIN}_notification" +NOTIFICATION_TITLE = f"{DEFAULT_NAME} Setup" + +DEFAULT_ICON = "mdi:alarm-light" +SCHEDULE_ICON = "mdi:calendar-clock" +ATTR_FRIENDLY_NAME = "friendly_name" + +PROTOCOLS = {True: "https", False: "http"} + +SCAN_INTERVAL = timedelta(seconds=60) + +DEFAULT_FORCE_UPDATE = False + +SENSOR_MAIN_NAME = "Main" + +MQTT_ALL_TOPIC = "shinobi" +DEFAULT_QOS = 0 + +ATTR_STATUS = [ +] + +DISCOVERY = f"{DOMAIN}_discovery" +DISCOVERY_BINARY_SENSOR = f"{DISCOVERY}_{DOMAIN_BINARY_SENSOR}" +DISCOVERY_CAMERA = f"{DISCOVERY}_{DOMAIN_CAMERA}" +DISCOVERY_SWITCH = f"{DISCOVERY}_{DOMAIN_SWITCH}" + +UPDATE_SIGNAL_CAMERA = f"{DOMAIN}_{DOMAIN_CAMERA}_UPDATE_SIGNAL" +UPDATE_SIGNAL_BINARY_SENSOR = f"{DOMAIN}_{DOMAIN_BINARY_SENSOR}_UPDATE_SIGNAL" + +SUPPORTED_DOMAINS = [DOMAIN_BINARY_SENSOR, DOMAIN_CAMERA] + +SIGNALS = { + DOMAIN_BINARY_SENSOR: UPDATE_SIGNAL_BINARY_SENSOR, + DOMAIN_CAMERA: UPDATE_SIGNAL_CAMERA, +} + +ENTITY_ID = "id" +ENTITY_NAME = "name" +ENTITY_STATE = "state" +ENTITY_ATTRIBUTES = "attributes" +ENTITY_ICON = "icon" +ENTITY_UNIQUE_ID = "unique-id" +ENTITY_EVENT = "event-type" +ENTITY_TOPIC = "topic" +ENTITY_DEVICE_CLASS = "device-class" +ENTITY_DEVICE_NAME = "device-name" +ENTITY_CAMERA_DETAILS = "camera-details" +ENTITY_BINARY_SENSOR_TYPE = "binary-sensor-type" +ENTITY_DISABLED = "disabled" + + +ENTITY_STATUS = "entity-status" +ENTITY_STATUS_EMPTY = None +ENTITY_STATUS_READY = f"{ENTITY_STATUS}-ready" +ENTITY_STATUS_CREATED = f"{ENTITY_STATUS}-created" + +CONF_CLEAR_CREDENTIALS = "clear-credentials" + +DOMAIN_LOAD = "load" +DOMAIN_UNLOAD = "unload" + +CONF_CONTENT_TYPE = "content_type" +CONF_LIMIT_REFETCH_TO_URL_CHANGE = "limit_refetch_to_url_change" +CONF_STILL_IMAGE_URL = "still_image_url" +CONF_STREAM_SOURCE = "stream_source" +CONF_FRAMERATE = "framerate" + +LOG_LEVEL_DEFAULT = "Default" +LOG_LEVEL_DEBUG = "Debug" +LOG_LEVEL_INFO = "Info" +LOG_LEVEL_WARNING = "Warning" +LOG_LEVEL_ERROR = "Error" + +LOG_LEVELS = [ + LOG_LEVEL_DEFAULT, + LOG_LEVEL_DEBUG, + LOG_LEVEL_INFO, + LOG_LEVEL_WARNING, + LOG_LEVEL_ERROR, +] + +URL_LOGIN = "?json=true" +URL_MONITORS = "[AUTH_TOKEN]/monitor/[GROUP_ID]" +URL_API_KEYS = "[AUTH_TOKEN]/api/[GROUP_ID]/list" + +RESPONSE_CHECK = { + URL_LOGIN: True, + URL_MONITORS: False, + URL_API_KEYS: True +} + +AUTH_TOKEN = "[AUTH_TOKEN]" +GROUP_ID = "[GROUP_ID]" +MONITOR_ID = "[MONITOR_ID]" + +LOGIN_USERNAME = "mail" +LOGIN_PASSWORD = "pass" +LOGIN_FUNCTION = "function" +LOGIN_DASH = "dash" + +DEFAULT_ACCESS_DETAILS = { + "auth_socket": False, + "get_monitors": True, + "control_monitors": False, + "get_logs": False, + "watch_stream": True, + "watch_snapshot": True, + "watch_videos": True, + "delete_videos": False + } + +ATTR_CAMERA_MONITOR_ID = "mid" +ATTR_CAMERA_GROUP_ID = "ke" +ATTR_CAMERA_NAME = "name" +ATTR_CAMERA_TYPE = "type" +ATTR_CAMERA_EXTENSION = "ext" +ATTR_CAMERA_PROTOCOL = "protocol" +ATTR_CAMERA_HOST = "host" +ATTR_CAMERA_PATH = "path" +ATTR_CAMERA_PORT = "port" +ATTR_CAMERA_MODE = "mode" +ATTR_CAMERA_STATUS = "status" +ATTR_CAMERA_SNAPSHOT = "snapshot" +ATTR_CAMERA_STREAMS = "streams" +ATTR_CAMERA_DETAILS = "details" +ATTR_CAMERA_DETAILS_FPS = "stream_fps" +ATTR_CAMERA_DETAILS_AUDIO_CODEC = "acodec" +ATTR_CAMERA_DETAILS_DETECTOR = "detector" +ATTR_CAMERA_DETAILS_DETECTOR_AUDIO = "detector_audio" +ATTR_FPS = "fps" + +CAMERA_ATTRIBUTES = { + "status": "Status", + "mode": "Mode", + "type": "Type" +} + +CAMERA_DETAILS_ATTRIBUTES = { + ATTR_CAMERA_DETAILS_FPS: ATTR_FPS +} + +TRIGGER_NAME = "name" +TRIGGER_DETAILS = "details" +TRIGGER_DETAILS_PLUG = "plug" +TRIGGER_DETAILS_REASON = "reason" +TRIGGER_DETAILS_MATRICES = "matrices" +TRIGGER_DETAILS_MATRICES_TAG = "tag" + +TRIGGER_PLUG_YOLO = "Yolo" +TRIGGER_PLUG_DB = "audio" + +TRIGGER_TAGS = "tags" +TRIGGER_STATE = "state" +TRIGGER_TIMESTAMP = "timestamp" + +MOTION_DETECTION = "Motion Detection" +SOUND_DETECTION = "Sound Detection" + +SENSOR_DEVICE_CLASS = { + TRIGGER_PLUG_YOLO: "motion", + TRIGGER_PLUG_DB: "sound" +} + +TRIGGER_DURATION = { + TRIGGER_PLUG_YOLO: 20, + TRIGGER_PLUG_DB: 10 +} + +TRIGGER_DEFAULT = { + TRIGGER_STATE: STATE_OFF +} + +BINARY_SENSOR_ATTRIBUTES = [TRIGGER_NAME, TRIGGER_DETAILS_REASON, TRIGGER_TAGS] diff --git a/custom_components/shinobi/managers/__init__.py b/custom_components/shinobi/managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/shinobi/managers/config_flow_manager.py b/custom_components/shinobi/managers/config_flow_manager.py new file mode 100644 index 0000000..8d93444 --- /dev/null +++ b/custom_components/shinobi/managers/config_flow_manager.py @@ -0,0 +1,247 @@ +import logging +from typing import Any, Dict, Optional + +from cryptography.fernet import InvalidToken +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry + +from .. import get_ha +from ..api.shinobi_api import ShinobiApi +from ..helpers.const import * +from ..managers.configuration_manager import ConfigManager +from ..managers.password_manager import PasswordManager +from ..models import LoginError +from ..models.config_data import ConfigData + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlowManager: + _config_manager: ConfigManager + _password_manager: PasswordManager + _options: Optional[dict] + _data: Optional[dict] + _config_entry: Optional[ConfigEntry] + api: Optional[ShinobiApi] + title: str + + def __init__(self): + self._config_entry = None + + self._options = None + self._data = None + + self._is_initialized = True + self._hass = None + self.api = None + self.title = DEFAULT_NAME + + async def initialize(self, hass, config_entry: Optional[ConfigEntry] = None): + self._config_entry = config_entry + self._hass = hass + + self._password_manager = PasswordManager(self._hass) + self._config_manager = ConfigManager(self._password_manager) + + data = {} + options = {} + + if self._config_entry is not None: + data = self._config_entry.data + options = self._config_entry.options + + self.title = self._config_entry.title + + await self.update_data(data, CONFIG_FLOW_INIT) + await self.update_options(options, CONFIG_FLOW_INIT) + + @property + def config_data(self) -> ConfigData: + return self._config_manager.data + + async def update_options(self, options: dict, flow: str): + _LOGGER.debug(f"update_options, options: {options}, flow: {flow}") + validate_login = False + + new_options = await self._clone_items(options, flow) + + if flow == CONFIG_FLOW_OPTIONS: + validate_login = self._should_validate_login(new_options) + + self._move_option_to_data(new_options) + + self._options = new_options + + await self._update_entry() + + if validate_login: + await self._handle_data(flow) + + return new_options + + async def update_data(self, data: dict, flow: str): + _LOGGER.debug(f"update_data, data: {data}, flow: {flow}") + + self._data = await self._clone_items(data, flow) + + await self._update_entry() + + await self._handle_data(flow) + + return self._data + + def _get_default_fields( + self, flow, config_data: Optional[ConfigData] = None + ) -> Dict[vol.Marker, Any]: + _LOGGER.debug(f"_get_default_fields, config_data: {config_data}") + + if config_data is None: + config_data = self.config_data + + fields = { + vol.Optional(CONF_HOST, default=config_data.host): str, + vol.Optional(CONF_PORT, default=config_data.port): str, + vol.Optional(CONF_SSL, default=config_data.ssl): bool, + vol.Optional(CONF_USERNAME, default=config_data.username): str, + vol.Optional(CONF_PASSWORD, default=config_data.password_clear_text): str, + } + + return fields + + async def get_default_data(self, user_input) -> vol.Schema: + _LOGGER.debug(f"get_default_data, user_input: {user_input}") + + config_data = await self._config_manager.get_basic_data(user_input) + + fields = self._get_default_fields(CONFIG_FLOW_DATA, config_data) + + data_schema = vol.Schema(fields) + + return data_schema + + def get_default_options(self) -> vol.Schema: + fields = self._get_default_fields(CONFIG_FLOW_OPTIONS) + + data_schema = vol.Schema(fields) + + return data_schema + + async def _update_entry(self): + try: + _LOGGER.debug(f"_update_entry, data: {self._data}") + + entry = ConfigEntry( + 0, "", "", self._data, "", "", {}, options=self._options + ) + + await self._config_manager.update(entry) + except InvalidToken: + _LOGGER.info("Reset password") + + del self._data[CONF_PASSWORD] + + entry = ConfigEntry( + 0, "", "", self._data, "", "", {}, options=self._options + ) + + await self._config_manager.update(entry) + + async def _handle_password(self, user_input): + _LOGGER.debug(f"_handle_password, user_input: {user_input}") + + if CONF_PASSWORD in user_input: + password_clear_text = user_input[CONF_PASSWORD] + password = await self._password_manager.encrypt(password_clear_text) + + user_input[CONF_PASSWORD] = password + + async def _clone_items(self, user_input, flow: str): + _LOGGER.debug(f"_clone_items, user_input: {user_input}, flow: {flow}") + + new_user_input = {} + + if user_input is not None: + for key in user_input: + user_input_data = user_input[key] + + new_user_input[key] = user_input_data + + if flow != CONFIG_FLOW_INIT: + await self._handle_password(new_user_input) + + return new_user_input + + @staticmethod + def clone_items(user_input): + _LOGGER.debug(f"clone_items, user_input: {user_input}") + + new_user_input = {} + + if user_input is not None: + for key in user_input: + user_input_data = user_input[key] + + new_user_input[key] = user_input_data + + return new_user_input + + def _should_validate_login(self, user_input: dict): + _LOGGER.debug(f"_should_validate_login, user_input: {user_input}") + + validate_login = False + data = self._data + + for conf in CONF_ARR: + if data.get(conf) != user_input.get(conf): + validate_login = True + + break + + return validate_login + + def _get_ha(self, key: str = None): + if key is None: + key = self.title + + ha = get_ha(self._hass, key) + + return ha + + def _move_option_to_data(self, options): + _LOGGER.debug(f"_move_option_to_data, options: {options}") + + for conf in CONF_ARR: + if conf in options: + self._data[conf] = options[conf] + + del options[conf] + + async def _handle_data(self, flow): + _LOGGER.debug(f"_handle_data, flow: {flow}") + + if flow != CONFIG_FLOW_INIT: + await self._valid_login() + + if flow == CONFIG_FLOW_OPTIONS: + config_entries = self._hass.config_entries + config_entries.async_update_entry(self._config_entry, data=self._data) + + async def _valid_login(self): + _LOGGER.debug(f"_valid_login") + + errors = None + + config_data = self._config_manager.data + + api = ShinobiApi(self._hass, self._config_manager) + await api.initialize() + + is_logged_in = await api.login() + + if not is_logged_in: + _LOGGER.warning(f"Failed to access Shinobi Video server ({config_data.host})") + errors = {"base": "invalid_server_details"} + + if errors is not None: + raise LoginError(errors) diff --git a/custom_components/shinobi/managers/configuration_manager.py b/custom_components/shinobi/managers/configuration_manager.py new file mode 100644 index 0000000..746f95d --- /dev/null +++ b/custom_components/shinobi/managers/configuration_manager.py @@ -0,0 +1,53 @@ +import logging + +from homeassistant.config_entries import ConfigEntry + +from ..helpers.const import * +from ..models.config_data import ConfigData +from .password_manager import PasswordManager + +_LOGGER = logging.getLogger(__name__) + + +class ConfigManager: + data: ConfigData + config_entry: ConfigEntry + password_manager: PasswordManager + + def __init__(self, password_manager: PasswordManager): + self.password_manager = password_manager + + async def update(self, config_entry: ConfigEntry): + data = config_entry.data + options = config_entry.options + + result: ConfigData = await self.get_basic_data(data) + + result.log_level = options.get(CONF_LOG_LEVEL, LOG_LEVEL_DEFAULT) + + self.config_entry = config_entry + self.data = result + + async def get_basic_data(self, data): + _LOGGER.debug(f"get_basic_data, data: {data}") + + result = ConfigData() + + if data is not None: + result.host = data.get(CONF_HOST) + result.port = data.get(CONF_PORT, 8080) + result.ssl = data.get(CONF_SSL, False) + + result.username = data.get(CONF_USERNAME) + result.password = data.get(CONF_PASSWORD) + + if result.password is not None and len(result.password) > 0: + password_clear_text = await self.password_manager.decrypt( + result.password + ) + + result.password_clear_text = password_clear_text + else: + result.password_clear_text = result.password + + return result diff --git a/custom_components/shinobi/managers/device_manager.py b/custom_components/shinobi/managers/device_manager.py new file mode 100644 index 0000000..a3ce4eb --- /dev/null +++ b/custom_components/shinobi/managers/device_manager.py @@ -0,0 +1,97 @@ +import logging + +from homeassistant.helpers.device_registry import async_get_registry + +from ..helpers.const import * +from .configuration_manager import ConfigManager +from ..models.camera_data import CameraData + +_LOGGER = logging.getLogger(__name__) + + +class DeviceManager: + def __init__(self, hass, ha): + self._hass = hass + self._ha = ha + + self._devices = {} + + self._api = self._ha.api + + @property + def config_manager(self) -> ConfigManager: + return self._ha.config_manager + + async def async_remove_entry(self, entry_id): + dr = await async_get_registry(self._hass) + dr.async_clear_config_entry(entry_id) + + async def delete_device(self, name): + _LOGGER.info(f"Deleting device {name}") + + device = self._devices[name] + + device_identifiers = device.get("identifiers") + device_connections = device.get("connections", {}) + + dr = await async_get_registry(self._hass) + + device = dr.async_get_device(device_identifiers, device_connections) + + if device is not None: + dr.async_remove_device(device.id) + + async def async_remove(self): + for device_name in self._devices: + await self.delete_device(device_name) + + def get(self, name): + return self._devices.get(name, {}) + + def set(self, name, device_info): + self._devices[name] = device_info + + def update(self): + self.generate_system_device() + camera_list = self._api.camera_list + + for camera in camera_list: + self.generate_camera_device(camera) + + def get_system_device_name(self): + title = self.config_manager.config_entry.title + + device_name = f"{title} Server" + + return device_name + + def get_camera_device_name(self, camera: CameraData): + title = self.config_manager.config_entry.title + + device_name = f"{title} {camera.name} ({camera.monitorId})" + + return device_name + + def generate_system_device(self): + device_name = self.get_system_device_name() + + device_info = { + "identifiers": {(DEFAULT_NAME, device_name)}, + "name": device_name, + "manufacturer": DEFAULT_NAME, + "model": "Server" + } + + self.set(device_name, device_info) + + def generate_camera_device(self, camera): + device_name = self.get_camera_device_name(camera) + + device_info = { + "identifiers": {(DEFAULT_NAME, device_name)}, + "name": device_name, + "manufacturer": DEFAULT_NAME, + "model": "Camera", + } + + self.set(device_name, device_info) diff --git a/custom_components/shinobi/managers/entity_manager.py b/custom_components/shinobi/managers/entity_manager.py new file mode 100644 index 0000000..096c206 --- /dev/null +++ b/custom_components/shinobi/managers/entity_manager.py @@ -0,0 +1,396 @@ +import logging +import sys +from typing import Dict, List, Optional + +from homeassistant.components.camera import DEFAULT_CONTENT_TYPE +from homeassistant.components.stream import DOMAIN as DOMAIN_STREAM +from homeassistant.const import CONF_AUTHENTICATION +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import EntityRegistry + +from .mqtt_manager import MQTTManager +from ..api.shinobi_api import ShinobiApi +from ..helpers.const import * +from ..models.camera_data import CameraData +from ..models.config_data import ConfigData +from ..models.entity_data import EntityData +from .configuration_manager import ConfigManager +from .device_manager import DeviceManager + +_LOGGER = logging.getLogger(__name__) + + +class EntityManager: + hass: HomeAssistant + ha = None + entities: dict + domain_component_manager: dict + + def __init__(self, hass, ha): + self.hass = hass + self.ha = ha + self.domain_component_manager = {} + self.entities = {} + + @property + def entity_registry(self) -> EntityRegistry: + return self.ha.entity_registry + + @property + def config_data(self) -> ConfigData: + return self.ha.config_data + + @property + def config_manager(self) -> ConfigManager: + return self.ha.config_manager + + @property + def api(self) -> ShinobiApi: + return self.ha.api + + @property + def mqtt_client(self) -> MQTTManager: + return self.ha.mqtt_manager + + @property + def device_manager(self) -> DeviceManager: + return self.ha.device_manager + + @property + def mqtt_manager(self) -> MQTTManager: + return self.ha.mqtt_manager + + @property + def integration_title(self) -> str: + return self.config_manager.config_entry.title + + def set_domain_component(self, domain, async_add_entities, component): + self.domain_component_manager[domain] = { + "async_add_entities": async_add_entities, + "component": component, + } + + def is_device_name_in_use(self, device_name): + result = False + + for entity in self.get_all_entities(): + if entity.device_name == device_name: + result = True + break + + return result + + def get_all_entities(self) -> List[EntityData]: + entities = [] + for domain in self.entities: + for name in self.entities[domain]: + entity = self.entities[domain][name] + + entities.append(entity) + + return entities + + def check_domain(self, domain): + if domain not in self.entities: + self.entities[domain] = {} + + def get_entities(self, domain) -> Dict[str, EntityData]: + self.check_domain(domain) + + return self.entities[domain] + + def get_entity(self, domain, name) -> Optional[EntityData]: + entities = self.get_entities(domain) + entity = entities.get(name) + + return entity + + def get_entity_status(self, domain, name): + entity = self.get_entity(domain, name) + + status = ENTITY_STATUS_EMPTY if entity is None else entity.status + + return status + + def set_entity_status(self, domain, name, status): + if domain in self.entities and name in self.entities[domain]: + self.entities[domain][name].status = status + + def delete_entity(self, domain, name): + if domain in self.entities and name in self.entities[domain]: + del self.entities[domain][name] + + def set_entity(self, domain, name, data: EntityData): + try: + self.check_domain(domain) + + self.entities[domain][name] = data + except Exception as ex: + self.log_exception( + ex, f"Failed to set_entity, domain: {domain}, name: {name}" + ) + + def create_components(self): + available_camera = self.api.camera_list + + mqtt_binary_sensors = [] + + for camera in available_camera: + self.generate_camera_component(camera) + + if self.mqtt_manager.is_supported: + current_mqtt_binary_sensors = self.generate_camera_binary_sensors(camera) + + mqtt_binary_sensors.extend(current_mqtt_binary_sensors) + + def update(self): + self.hass.async_create_task(self._async_update()) + + async def _async_update(self): + step = "Mark as ignore" + try: + entities_to_delete = [] + + for entity in self.get_all_entities(): + entities_to_delete.append(entity.unique_id) + + step = "Create components" + + self.create_components() + + step = "Start updating" + + for domain in SIGNALS: + step = f"Start updating domain {domain}" + + entities_to_add = [] + domain_component_manager = self.domain_component_manager[domain] + domain_component = domain_component_manager["component"] + async_add_entities = domain_component_manager["async_add_entities"] + + entities = dict(self.get_entities(domain)) + + for entity_key in entities: + step = f"Start updating {domain} -> {entity_key}" + + entity = entities[entity_key] + + entity_id = self.entity_registry.async_get_entity_id( + domain, DOMAIN, entity.unique_id + ) + + if entity.status == ENTITY_STATUS_CREATED: + entity_item = self.entity_registry.async_get(entity_id) + + if entity.unique_id in entities_to_delete: + entities_to_delete.remove(entity.unique_id) + + step = f"Mark as created - {domain} -> {entity_key}" + + entity_component = domain_component( + self.hass, self.config_manager.config_entry.entry_id, entity + ) + + if entity_id is not None: + entity_component.entity_id = entity_id + + state = self.hass.states.get(entity_id) + + if state is None: + restored = True + else: + restored = state.attributes.get("restored", False) + + if restored: + _LOGGER.info( + f"Entity {entity.name} restored | {entity_id}" + ) + + if restored: + if entity_item is None or not entity_item.disabled: + entities_to_add.append(entity_component) + else: + entities_to_add.append(entity_component) + + entity.status = ENTITY_STATUS_READY + + if entity_item is not None: + entity.disabled = entity_item.disabled + + step = f"Add entities to {domain}" + + if len(entities_to_add) > 0: + async_add_entities(entities_to_add, True) + + if len(entities_to_delete) > 0: + _LOGGER.info(f"Following items will be deleted: {entities_to_delete}") + + for domain in SIGNALS: + entities = dict(self.get_entities(domain)) + + for entity_key in entities: + entity = entities[entity_key] + if entity.unique_id in entities_to_delete: + await self.ha.delete_entity(domain, entity.name) + + except Exception as ex: + self.log_exception(ex, f"Failed to update, step: {step}") + + def get_camera_entity(self, camera: CameraData, sensor_type) -> EntityData: + entity = None + + try: + device_name = self.device_manager.get_camera_device_name(camera) + + device_class = SENSOR_DEVICE_CLASS.get(sensor_type, sensor_type) + + entity_name = f"{self.integration_title} {camera.name} {device_class.capitalize()}" + unique_id = f"{DOMAIN}-{DOMAIN_BINARY_SENSOR}-{entity_name}" + + state_topic = f"{MQTT_ALL_TOPIC}/{self.api.group_id}/{camera.monitorId}/trigger" + + mqtt_state = TRIGGER_DEFAULT + + if self.mqtt_manager is not None: + mqtt_state = self.mqtt_manager.get_state(state_topic, sensor_type) + + attributes = { + ATTR_FRIENDLY_NAME: entity_name + } + + for attr in BINARY_SENSOR_ATTRIBUTES: + if attr in mqtt_state: + attributes[attr] = mqtt_state.get(attr) + + entity = EntityData() + + entity.id = camera.monitorId + entity.unique_id = unique_id + entity.name = entity_name + entity.state = mqtt_state.get(TRIGGER_STATE) + entity.attributes = attributes + entity.icon = DEFAULT_ICON + entity.device_name = device_name + entity.topic = state_topic + entity.event = sensor_type + entity.device_class = device_class.lower() + entity.type = sensor_type + + self.mqtt_manager.set_state(state_topic, sensor_type, mqtt_state) + + self.set_entity(DOMAIN_BINARY_SENSOR, entity_name, entity) + except Exception as ex: + self.log_exception( + ex, f"Failed to get camera for {camera.name}" + ) + + return entity + + def generate_camera_binary_sensors(self, camera: CameraData): + entities = [] + + try: + supported_sensors = [] + if camera.has_audio and camera.has_audio_detector: + supported_sensors.append(TRIGGER_PLUG_DB) + + if camera.has_motion_detector: + supported_sensors.append(TRIGGER_PLUG_YOLO) + + for sensor_type_name in supported_sensors: + entity = self.get_camera_entity(camera, sensor_type_name) + + entities.append(entity) + + except Exception as ex: + self.log_exception(ex, f"Failed to generate binary sensors for {camera}") + + return entities + + def get_camera_component(self, camera: CameraData) -> EntityData: + entity = None + try: + device_name = self.device_manager.get_camera_device_name(camera) + + entity_name = f"{self.integration_title} {camera.name}" + username = self.config_data.username + password = self.config_data.password_clear_text + base_url = self.api.base_url + + unique_id = f"{DOMAIN}-{DOMAIN_CAMERA}-{entity_name}" + + snapshot = f"{base_url}{camera.snapshot}" + still_image_url_template = cv.template(snapshot) + + support_stream = DOMAIN_STREAM in self.hass.data + + stream_source = "" + + for stream in camera.streams: + stream_source = f"{base_url}{stream}" + + break + + camera_details = { + CONF_NAME: f"{entity_name}", + CONF_STILL_IMAGE_URL: still_image_url_template, + CONF_STREAM_SOURCE: stream_source, + CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, + CONF_FRAMERATE: camera.fps, + CONF_CONTENT_TYPE: DEFAULT_CONTENT_TYPE, + CONF_VERIFY_SSL: False, + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_AUTHENTICATION: AUTHENTICATION_BASIC, + CONF_SUPPORT_STREAM: support_stream, + } + + attributes = { + ATTR_FRIENDLY_NAME: entity_name, + CONF_STREAM_SOURCE: stream_source, + CONF_STILL_IMAGE_URL: snapshot + } + + for key in CAMERA_ATTRIBUTES: + key_name = CAMERA_ATTRIBUTES[key] + attributes[key_name] = camera.details.get(key, "N/A") + + monitor_details = camera.details.get("details", {}) + + for key in CAMERA_DETAILS_ATTRIBUTES: + key_name = CAMERA_DETAILS_ATTRIBUTES[key] + attributes[key_name] = monitor_details.get(key, "N/A") + + entity = EntityData() + + entity.id = camera.monitorId + entity.unique_id = unique_id + entity.name = entity_name + entity.attributes = attributes + entity.icon = DEFAULT_ICON + entity.device_name = device_name + entity.details = camera_details + entity.state = camera.status + + except Exception as ex: + self.log_exception(ex, f"Failed to get camera for {camera}") + + return entity + + def generate_camera_component(self, camera: CameraData): + try: + entity = self.get_camera_component(camera) + + if entity is not None: + self.set_entity(DOMAIN_CAMERA, entity.name, entity) + + except Exception as ex: + self.log_exception(ex, f"Failed to generate camera for {camera}") + + @staticmethod + def log_exception(ex, message): + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"{message}, Error: {str(ex)}, Line: {line_number}") diff --git a/custom_components/shinobi/managers/home_assistant.py b/custom_components/shinobi/managers/home_assistant.py new file mode 100644 index 0000000..d1b9dc5 --- /dev/null +++ b/custom_components/shinobi/managers/home_assistant.py @@ -0,0 +1,249 @@ +""" +Support for Shinobi Video. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/shinobi/ +""" +from datetime import datetime +import logging +import sys +from typing import Optional + +from cryptography.fernet import InvalidToken + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import ( + EntityRegistry, + async_get_registry as er_async_get_registry, +) +from homeassistant.helpers.event import async_track_time_interval + +from .mqtt_manager import MQTTManager +from ..api.shinobi_api import ShinobiApi +from ..helpers.const import * +from ..models.config_data import ConfigData +from .configuration_manager import ConfigManager +from .device_manager import DeviceManager +from .entity_manager import EntityManager +from .password_manager import PasswordManager +from .storage_manager import StorageManager + +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantManager: + def __init__(self, hass: HomeAssistant, password_manager: PasswordManager): + self._hass = hass + + self._remove_async_track_time = None + + self._is_initialized = False + self._is_updating = False + + self._entity_registry = None + + self._api = None + self._entity_manager = None + self._device_manager = None + self._storage_manager = None + + self._config_manager = ConfigManager(password_manager) + self._mqtt_manager = None + + @property + def api(self) -> ShinobiApi: + return self._api + + @property + def entity_manager(self) -> EntityManager: + return self._entity_manager + + @property + def device_manager(self) -> DeviceManager: + return self._device_manager + + @property + def entity_registry(self) -> EntityRegistry: + return self._entity_registry + + @property + def config_manager(self) -> ConfigManager: + return self._config_manager + + @property + def storage_manager(self) -> StorageManager: + return self._storage_manager + + @property + def config_data(self) -> Optional[ConfigData]: + if self._config_manager is not None: + return self._config_manager.data + + return None + + @property + def mqtt_manager(self) -> MQTTManager: + return self._mqtt_manager + + async def async_init(self, entry: ConfigEntry): + try: + self._storage_manager = StorageManager(self._hass) + + await self._config_manager.update(entry) + + self._api = ShinobiApi(self._hass, self._config_manager) + self._entity_manager = EntityManager(self._hass, self) + self._device_manager = DeviceManager(self._hass, self) + + self._mqtt_manager = MQTTManager(self._hass, self._api, self.mqtt_event_handler) + + self._entity_registry = await er_async_get_registry(self._hass) + + self._hass.loop.create_task(self._async_init()) + except InvalidToken: + error_message = "Encryption key got corrupted, please remove the integration and re-add it" + + _LOGGER.error(error_message) + + data = await self._storage_manager.async_load_from_store() + data.key = None + await self._storage_manager.async_save_to_store(data) + + await self._hass.services.async_call( + "persistent_notification", + "create", + {"title": DEFAULT_NAME, "message": error_message}, + ) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to async_init, error: {ex}, line: {line_number}") + + async def _async_init(self): + load = self._hass.config_entries.async_forward_entry_setup + + for domain in SIGNALS: + await load(self._config_manager.config_entry, domain) + + self._is_initialized = True + + await self.async_update_entry() + + def _update_entities(self, now): + self._hass.async_create_task(self.async_update(now)) + + async def async_update_entry(self, entry: ConfigEntry = None): + update_config_manager = entry is not None + + if not update_config_manager: + entry = self._config_manager.config_entry + + self._remove_async_track_time = async_track_time_interval( + self._hass, self._update_entities, SCAN_INTERVAL + ) + + if not self._is_initialized: + _LOGGER.info( + f"NOT INITIALIZED - Failed handling ConfigEntry change: {entry.as_dict()}" + ) + return + + _LOGGER.info(f"Handling ConfigEntry change: {entry.as_dict()}") + + if update_config_manager: + await self._config_manager.update(entry) + + await self._api.initialize() + await self._api.login() + + if self.mqtt_manager.is_supported: + await self.mqtt_manager.initialize() + + await self.async_update(datetime.now()) + + async def async_remove(self, entry: ConfigEntry): + _LOGGER.info(f"Removing current integration - {entry.title}") + + if self._remove_async_track_time is not None: + self._remove_async_track_time() + self._remove_async_track_time = None + + unload = self._hass.config_entries.async_forward_entry_unload + + for domain in SUPPORTED_DOMAINS: + await unload(entry, domain) + + await self._device_manager.async_remove() + + self.mqtt_manager.terminate() + + _LOGGER.info(f"Current integration ({entry.title}) removed") + + def mqtt_event_handler(self): + self.entity_manager.update() + + self._hass.async_create_task(self.dispatch_all()) + + async def async_update(self, event_time): + if not self._is_initialized: + _LOGGER.info(f"NOT INITIALIZED - Failed updating @{event_time}") + return + + try: + if self._is_updating: + _LOGGER.debug(f"Skip updating @{event_time}") + return + + _LOGGER.debug(f"Updating @{event_time}") + + self._is_updating = True + + await self._api.async_update() + + self.device_manager.update() + self.entity_manager.update() + + await self.dispatch_all() + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to async_update, Error: {ex}, Line: {line_number}") + + self._is_updating = False + + async def delete_entity(self, domain, name): + try: + entity = self.entity_manager.get_entity(domain, name) + device_name = entity.device_name + unique_id = entity.unique_id + + self.entity_manager.delete_entity(domain, name) + + device_in_use = self.entity_manager.is_device_name_in_use(device_name) + + entity_id = self.entity_registry.async_get_entity_id( + domain, DOMAIN, unique_id + ) + self.entity_registry.async_remove(entity_id) + + if not device_in_use: + await self.device_manager.delete_device(device_name) + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to delete_entity, Error: {ex}, Line: {line_number}") + + async def dispatch_all(self): + if not self._is_initialized: + _LOGGER.info("NOT INITIALIZED - Failed discovering components") + return + + for domain in SUPPORTED_DOMAINS: + signal = SIGNALS.get(domain) + + async_dispatcher_send(self._hass, signal) diff --git a/custom_components/shinobi/managers/mqtt_manager.py b/custom_components/shinobi/managers/mqtt_manager.py new file mode 100644 index 0000000..aaeb75b --- /dev/null +++ b/custom_components/shinobi/managers/mqtt_manager.py @@ -0,0 +1,108 @@ +import json +import logging +from datetime import datetime +from typing import Optional + +from homeassistant.components.mqtt import Message, async_subscribe +from homeassistant.core import callback, HomeAssistant + +from custom_components.shinobi.api.shinobi_api import ShinobiApi +from custom_components.shinobi.helpers.const import * + +_LOGGER = logging.getLogger(__name__) + + +def _get_camera_binary_sensor_key(topic, event_type): + key = f"{topic}_{event_type}".lower() + + return key + + +class MQTTManager: + remove_subscription = None + hass: Optional[HomeAssistant] = None + message_handler = None + mqtt_states: dict + api: ShinobiApi + + def __init__(self, hass: HomeAssistant, api: ShinobiApi, event_handler): + self.remove_subscription = None + self.hass = hass + self.event_handler = event_handler + self.mqtt_states = {} + self.api = api + + @property + def is_supported(self) -> bool: + return DATA_MQTT in self.hass.data + + async def initialize(self): + """Subscribe MQTT events.""" + self.terminate() + + mqtt_topic = f"{MQTT_ALL_TOPIC}/{self.api.group_id}/#" + + _LOGGER.info( + f"Subscribing to MQTT topics '{mqtt_topic}', QOS: {DEFAULT_QOS}" + ) + + @callback + def state_message_received(message: Message): + """Handle a new received MQTT state message.""" + _LOGGER.debug( + f"Received Shinobi Video Message - {message.topic}: {message.payload}" + ) + + self._state_message_received(message) + + self.remove_subscription = await async_subscribe( + self.hass, mqtt_topic, state_message_received, DEFAULT_QOS + ) + + def terminate(self): + if self.remove_subscription is not None: + self.remove_subscription() + self.remove_subscription = None + + def _state_message_received(self, message: Message): + topic = message.topic + payload = json.loads(message.payload) + + trigger_name = payload.get(TRIGGER_NAME) + trigger_details = payload.get(TRIGGER_DETAILS, {}) + trigger_plug = trigger_details.get(TRIGGER_DETAILS_PLUG) + trigger_reason = trigger_details.get(TRIGGER_DETAILS_REASON) + trigger_matrices = trigger_details.get(TRIGGER_DETAILS_MATRICES, []) + + trigger_tags = [] + + for trigger_object in trigger_matrices: + trigger_tag = trigger_object.get(TRIGGER_DETAILS_MATRICES_TAG) + + if trigger_tag not in trigger_tags: + trigger_tags.append(trigger_tag) + + value = { + TRIGGER_NAME: trigger_name, + TRIGGER_DETAILS_REASON: trigger_reason, + TRIGGER_TAGS: trigger_tags, + TRIGGER_STATE: STATE_ON, + TRIGGER_TIMESTAMP: datetime.now().timestamp() + } + + _LOGGER.debug(f"Topic: {topic} for {trigger_plug}: {value}") + + self.set_state(topic, trigger_plug, value) + self.event_handler() + + def get_state(self, topic, event_type): + key = _get_camera_binary_sensor_key(topic, event_type) + + state = self.mqtt_states.get(key, TRIGGER_DEFAULT) + + return state + + def set_state(self, topic, event_type, value): + key = _get_camera_binary_sensor_key(topic, event_type) + + self.mqtt_states[key] = value diff --git a/custom_components/shinobi/managers/password_manager.py b/custom_components/shinobi/managers/password_manager.py new file mode 100644 index 0000000..3e56c9d --- /dev/null +++ b/custom_components/shinobi/managers/password_manager.py @@ -0,0 +1,58 @@ +import logging +from os import path, remove +from typing import Optional + +from cryptography.fernet import Fernet + +from homeassistant.core import HomeAssistant + +from ..helpers.const import * +from ..models.storage_data import StorageData +from .storage_manager import StorageManager + +_LOGGER = logging.getLogger(__name__) + + +class PasswordManager: + data: Optional[StorageData] + hass: HomeAssistant + crypto: Fernet + + def __init__(self, hass: HomeAssistant): + self.hass = hass + self.data = None + + async def _load_key(self): + if self.data is None: + storage_manager = StorageManager(self.hass) + + self.data = await storage_manager.async_load_from_store() + + if self.data.key is None: + legacy_key_path = self.hass.config.path(DOMAIN_KEY_FILE) + + if path.exists(legacy_key_path): + with open(legacy_key_path, "rb") as file: + self.data.key = file.read().decode("utf-8") + + remove(legacy_key_path) + else: + self.data.key = Fernet.generate_key().decode("utf-8") + + await storage_manager.async_save_to_store(self.data) + + self.crypto = Fernet(self.data.key.encode()) + + async def encrypt(self, data: str): + await self._load_key() + + encrypted = self.crypto.encrypt(data.encode()).decode() + + return encrypted + + async def decrypt(self, data: str): + await self._load_key() + + decrypted = self.crypto.decrypt(data.encode()).decode() + + return decrypted diff --git a/custom_components/shinobi/managers/storage_manager.py b/custom_components/shinobi/managers/storage_manager.py new file mode 100644 index 0000000..28ac51f --- /dev/null +++ b/custom_components/shinobi/managers/storage_manager.py @@ -0,0 +1,39 @@ +"""Storage handers.""" +import logging + +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import Store + +from ..helpers.const import * +from ..models.storage_data import StorageData + +STORAGE_VERSION = 1 + +_LOGGER = logging.getLogger(__name__) + + +class StorageManager: + def __init__(self, hass): + self._hass = hass + + @property + def file_name(self): + file_name = f".{DOMAIN}" + + return file_name + + async def async_load_from_store(self) -> StorageData: + """Load the retained data from store and return de-serialized data.""" + store = Store(self._hass, STORAGE_VERSION, self.file_name, encoder=JSONEncoder) + + data = await store.async_load() + + result = StorageData.from_dict(data) + + return result + + async def async_save_to_store(self, data: StorageData): + """Generate dynamic data to store and save it to the filesystem.""" + store = Store(self._hass, STORAGE_VERSION, self.file_name, encoder=JSONEncoder) + + await store.async_save(data.to_dict()) diff --git a/custom_components/shinobi/manifest.json b/custom_components/shinobi/manifest.json new file mode 100644 index 0000000..f0dd976 --- /dev/null +++ b/custom_components/shinobi/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "shinobi", + "name": "Shinobi Video NVR", + "documentation": "https://github.com/elad-bar/ha-shinobi", + "after_dependencies": [ + "mqtt" + ], + "dependencies": [ + "camera" + ], + "codeowners": ["@elad-bar"], + "requirements": [ ], + "config_flow": true, + "version": "1.0.0" + } diff --git a/custom_components/shinobi/models/__init__.py b/custom_components/shinobi/models/__init__.py new file mode 100644 index 0000000..c06f7d0 --- /dev/null +++ b/custom_components/shinobi/models/__init__.py @@ -0,0 +1,15 @@ +from homeassistant.exceptions import HomeAssistantError + + +class AlreadyExistsError(HomeAssistantError): + title: str + + def __init__(self, title: str): + self.title = title + + +class LoginError(HomeAssistantError): + errors: dict + + def __init__(self, errors): + self.errors = errors diff --git a/custom_components/shinobi/models/base_entity.py b/custom_components/shinobi/models/base_entity.py new file mode 100644 index 0000000..dcb9b01 --- /dev/null +++ b/custom_components/shinobi/models/base_entity.py @@ -0,0 +1,147 @@ +import logging +import sys +from typing import Any, Callable, Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from ..api.shinobi_api import ShinobiApi +from ..helpers import get_ha +from ..helpers.const import * +from .entity_data import EntityData +from ..managers.device_manager import DeviceManager +from ..managers.entity_manager import EntityManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_base_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities, + domain: str, + component: Callable[[HomeAssistant, Any, EntityData], Any], +): + + """Set up Shinobi Video based off an entry.""" + _LOGGER.debug(f"Starting async_setup_entry {domain}") + + try: + ha = get_ha(hass, entry.entry_id) + entity_manager = ha.entity_manager + + entity_manager.set_domain_component(domain, async_add_entities, component) + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error(f"Failed to load {domain}, error: {ex}, line: {line_number}") + + +class BaseEntity(Entity): + """Representation a binary sensor that is updated by Shinobi Video.""" + + hass: HomeAssistant = None + integration_name: str = None + entity: EntityData = None + remove_dispatcher = None + current_domain: str = None + + ha = None + entity_manager: EntityManager = None + device_manager: DeviceManager = None + api: ShinobiApi = None + + def initialize( + self, + hass: HomeAssistant, + integration_name: str, + entity: EntityData, + current_domain: str, + ): + self.hass = hass + self.integration_name = integration_name + self.entity = entity + self.remove_dispatcher = None + self.current_domain = current_domain + + self.ha = get_ha(self.hass, self.integration_name) + self.entity_manager = self.ha.entity_manager + self.device_manager = self.ha.device_manager + self.api = self.ha.api + + @property + def unique_id(self) -> Optional[str]: + """Return the name of the node.""" + return self.entity.unique_id + + @property + def device_info(self): + return self.device_manager.get(self.entity.device_name) + + @property + def name(self): + """Return the name of the node.""" + return self.entity.name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def device_state_attributes(self): + """Return true if the binary sensor is on.""" + return self.entity.attributes + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNALS[self.current_domain], self._schedule_immediate_update + ) + + await self.async_added_to_hass_local() + + async def async_will_remove_from_hass(self) -> None: + if self.remove_dispatcher is not None: + self.remove_dispatcher() + self.remove_dispatcher = None + + await self.async_will_remove_from_hass_local() + + @callback + def _schedule_immediate_update(self): + self.hass.async_create_task(self._async_schedule_immediate_update()) + + async def _async_schedule_immediate_update(self): + if self.entity_manager is None: + _LOGGER.debug( + f"Cannot update {self.current_domain} - Entity Manager is None | {self.name}" + ) + else: + if self.entity is not None: + previous_state = self.entity.state + + entity = self.entity_manager.get_entity(self.current_domain, self.name) + + if entity is None: + _LOGGER.debug(f"Skip updating {self.name}, Entity is None") + + elif entity.disabled: + _LOGGER.debug(f"Skip updating {self.name}, Entity is disabled") + + else: + self.entity = entity + if self.entity is not None: + self._immediate_update(previous_state) + + async def async_added_to_hass_local(self): + pass + + async def async_will_remove_from_hass_local(self): + pass + + def _immediate_update(self, previous_state: bool): + self.async_schedule_update_ha_state(True) diff --git a/custom_components/shinobi/models/camera_data.py b/custom_components/shinobi/models/camera_data.py new file mode 100644 index 0000000..a837e70 --- /dev/null +++ b/custom_components/shinobi/models/camera_data.py @@ -0,0 +1,47 @@ +import json + +from ..helpers.const import * + + +class CameraData: + monitorId: str + name: str + details: dict + has_audio: bool + has_audio_detector: bool + has_motion_detector: bool + fps: int + + def __init__(self, camera): + self.monitorId = camera.get(ATTR_CAMERA_MONITOR_ID) + self.name = camera.get(ATTR_CAMERA_NAME) + self.status = camera.get(ATTR_CAMERA_STATUS) + self.snapshot = camera.get(ATTR_CAMERA_SNAPSHOT) + self.streams = camera.get(ATTR_CAMERA_STREAMS) + self.details = camera + + monitor_details = camera.get("details", {}) + + self.fps = int(monitor_details.get(ATTR_CAMERA_DETAILS_FPS, "1")) + self.has_audio = monitor_details.get(ATTR_CAMERA_DETAILS_AUDIO_CODEC, "no") != "no" + self.has_audio_detector = monitor_details.get(ATTR_CAMERA_DETAILS_DETECTOR_AUDIO, "0") != "0" + self.has_motion_detector = monitor_details.get(ATTR_CAMERA_DETAILS_DETECTOR, "0") != "0" + + def __repr__(self): + obj = { + ATTR_CAMERA_MONITOR_ID: self.monitorId, + ATTR_CAMERA_NAME: self.name, + ATTR_CAMERA_STATUS: self.name, + ATTR_CAMERA_SNAPSHOT: self.snapshot, + ATTR_CAMERA_STREAMS: self.streams, + ATTR_CAMERA_DETAILS: self.details, + MOTION_DETECTION: self.has_motion_detector, + SOUND_DETECTION: self.has_audio_detector, + TRIGGER_PLUG_DB: self.has_audio, + ATTR_FPS: self.fps + + } + + to_string = f"{obj}" + + return to_string diff --git a/custom_components/shinobi/models/config_data.py b/custom_components/shinobi/models/config_data.py new file mode 100644 index 0000000..542d1db --- /dev/null +++ b/custom_components/shinobi/models/config_data.py @@ -0,0 +1,54 @@ +from typing import Optional + +from ..helpers.const import * + + +class ConfigData: + name: str + host: str + port: int + ssl: bool + username: Optional[str] + password: Optional[str] + password_clear_text: Optional[str] + log_level: str + + def __init__(self): + self.name = DEFAULT_NAME + self.host = "" + self.port = DEFAULT_PORT + self.ssl = False + self.username = None + self.password = None + self.password_clear_text = None + self.log_level = LOG_LEVEL_DEFAULT + + @property + def protocol(self): + protocol = PROTOCOLS[self.ssl] + + return protocol + + @property + def has_credentials(self): + has_username = self.username and len(self.username) > 0 + has_password = self.password_clear_text and len(self.password_clear_text) > 0 + + has_credentials = has_username or has_password + + return has_credentials + + def __repr__(self): + obj = { + CONF_NAME: self.name, + CONF_HOST: self.host, + CONF_PORT: self.port, + CONF_SSL: self.ssl, + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + CONF_LOG_LEVEL: self.log_level, + } + + to_string = f"{obj}" + + return to_string diff --git a/custom_components/shinobi/models/entity_data.py b/custom_components/shinobi/models/entity_data.py new file mode 100644 index 0000000..04f1a18 --- /dev/null +++ b/custom_components/shinobi/models/entity_data.py @@ -0,0 +1,56 @@ +from ..helpers.const import * + + +class EntityData: + id: str + unique_id: str + name: str + state: bool + attributes: dict + icon: str + device_name: str + status: str + topic: str + event: str + device_class: str + type: str + details: dict + disabled: bool + + def __init__(self): + self.id = "" + self.unique_id = "" + self.name = "" + self.state = False + self.attributes = {} + self.icon = "" + self.device_name = "" + self.status = ENTITY_STATUS_CREATED + self.topic = "" + self.event = "" + self.device_class = "" + self.type = "" + self.details = {} + self.disabled = False + + def __repr__(self): + obj = { + ENTITY_ID: self.id, + ENTITY_UNIQUE_ID: self.unique_id, + ENTITY_NAME: self.name, + ENTITY_STATE: self.state, + ENTITY_ATTRIBUTES: self.attributes, + ENTITY_ICON: self.icon, + ENTITY_DEVICE_NAME: self.device_name, + ENTITY_STATUS: self.status, + ENTITY_TOPIC: self.topic, + ENTITY_EVENT: self.event, + ENTITY_DEVICE_CLASS: self.device_class, + ENTITY_BINARY_SENSOR_TYPE: self.type, + ENTITY_CAMERA_DETAILS: self.details, + ENTITY_DISABLED: self.disabled, + } + + to_string = f"{obj}" + + return to_string diff --git a/custom_components/shinobi/models/storage_data.py b/custom_components/shinobi/models/storage_data.py new file mode 100644 index 0000000..7f130ae --- /dev/null +++ b/custom_components/shinobi/models/storage_data.py @@ -0,0 +1,48 @@ +from typing import Dict, Optional + +from ..helpers.const import * + + +class StorageIntegrationData: + generate_configuration_files: bool + + def __init__(self): + self.generate_configuration_files = False + + +class StorageData: + key: Optional[str] + integrations: Dict[str, StorageIntegrationData] + + def __init__(self): + self.key = None + self.integrations = {} + + @staticmethod + def from_dict(obj: dict): + data = StorageData() + + if obj is not None: + data.key = obj.get("key") + integrations = obj.get("integrations", {}) + + return data + + def to_dict(self): + integrations = {} + for integration_key in self.integrations: + current_integration = self.integrations[integration_key] + integration = { + "CONF_GENERATE_CONFIG_FILES": current_integration.generate_configuration_files + } + + integrations[integration_key] = integration + + obj = {"key": self.key, "integrations": integrations} + + return obj + + def __repr__(self): + to_string = f"{self.to_dict()}" + + return to_string diff --git a/custom_components/shinobi/services.yaml b/custom_components/shinobi/services.yaml new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/shinobi/strings.json b/custom_components/shinobi/strings.json new file mode 100644 index 0000000..e10871d --- /dev/null +++ b/custom_components/shinobi/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Shinobi Video", + "description": "Set up your BlueIris Server details (to control profiles, fill in valid username and password)", + "data": { + "host": "Host", + "port": "Port", + "username": "Username", + "password": "Password", + "ssl": "Is SSL" + } + } + }, + "error": { + "invalid_admin_credentials": "Invalid administrator credentials", + "invalid_server_details": "Invalid server details", + "already_configured": "Integration already configured with the name" + } + }, + "options": { + "step": { + "shinobi_additional_settings": { + "title": "Options for Shinobi Video.", + "description": "Set up username and password to control profiles / exclude system camera.", + "data": { + "host": "Host", + "port": "Port", + "ssl": "Is SSL", + "username": "Username", + "password": "Password", + "log_level": "Log level" + } + } + }, + "error": { + "invalid_admin_credentials": "Invalid administrator credentials", + "invalid_server_details": "Invalid server details", + "already_configured": "integration already configured with the name" + } + } +} \ No newline at end of file diff --git a/custom_components/shinobi/translations/en.json b/custom_components/shinobi/translations/en.json new file mode 100644 index 0000000..d8e2300 --- /dev/null +++ b/custom_components/shinobi/translations/en.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Shinobi Video", + "description": "Set up your BlueIris Server details (to control profiles, fill in valid username and password)", + "data": { + "host": "Host", + "port": "Port", + "username": "Username", + "password": "Password", + "ssl": "Is SSL" + } + } + }, + "error": { + "invalid_admin_credentials": "Invalid administrator credentials", + "invalid_server_details": "Invalid server details", + "already_configured": "Integration already configured with the name" + } + }, + "options": { + "step": { + "shinobi_additional_settings": { + "title": "Options for Shinobi Video.", + "description": "Set up username and password to control profiles / exclude system camera.", + "data": { + "host": "Host", + "port": "Port", + "ssl": "Is SSL", + "username": "Username", + "password": "Password", + "log_level": "Log level" + } + } + }, + "error": { + "invalid_admin_credentials": "Invalid administrator credentials", + "invalid_server_details": "Invalid server details", + "already_configured": "Integration already configured with the name" + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..6c40cef --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Shinobi Video NVR", + "iot_class": "Local Polling" +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..a072188 --- /dev/null +++ b/info.md @@ -0,0 +1,102 @@ +# Shinobi Video NVR + +## Description + +Integration with Shinobi Video NVR. Creates the following components: + +* Camera - per-camera defined. +* Binary Sensors (MOTION, SOUND) - per-camera defined. +* Support HLS Streams instead of H264. +* Support SSL with self-signed certificate. + +[Changelog](https://github.com/elad-bar/ha-shinobi/blob/master/CHANGELOG.md) + +## How to + +#### Requirements +- Shinobi Video Server available with credentials +- MQTT Integration is optional - it will allow to listen to BlueIris event + +#### Shinobi links: +- [Using MQTT to receive and trigger events](https://hub.shinobi.video/articles/view/xEMps3O4y4VEaYk) +- [How to use Motion Detection](https://hub.shinobi.video/articles/view/LKdcgcgWy9RJfUh) + + +#### Installations via HACS +Currently, repository is not under official HACS repo, in order to install, you will need to add manually the repository + +Look for "Shinobi Video NVR" and install + +#### Integration settings +###### Basic configuration (Configuration -> Integrations -> Add BlueIris) +Fields name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +Host | Texbox | + | None | Hostname or IP address of the BlueIris server +Port | Textbox | + | 0 | HTTP Port to access BlueIris server +SSL | Check-box | + | Unchecked | Is SSL supported? +Username | Textbox | - | | Username of admin user for BlueIris server +Password | Textbox | - | | Password of admin user for BlueIris server + +###### Integration options (Configuration -> Integrations -> BlueIris Integration -> Options) +Fields name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +Host | Texbox | + | ast stored hostname | Hostname or IP address of the BlueIris server +Port | Textbox | + | 0ast stored port | HTTP Port to access BlueIris server +SSL | Check-box | + | Last stored SSL flag | Is SSL supported? +Username | Textbox | - | Last stored username | Username of admin user for BlueIris server +Password | Textbox | - | Last stored password | Password of admin user for BlueIris server +Log level | Drop-down | + | Default | Changes component's log level (more details below) + +**Log Level's drop-down** +New feature to set the log level for the component without need to set log_level in `customization:` and restart or call manually `logger.set_level` and loose it after restart. + +Upon startup or integration's option update, based on the value chosen, the component will make a service call to `logger.set_level` for that component with the desired value, + +In case `Default` option is chosen, flow will skip calling the service, after changing from any other option to `Default`, it will not take place automatically, only after restart + +###### Configuration validations +Upon submitting the form of creating an integration or updating options, + +Component will try to log in into the Shinobi Video server to verify new settings, following errors can appear: +- Integration already configured with the same title +- Invalid server details - Cannot reach the server + +###### Encryption key got corrupted +If a persistent notification popped up with the following message: +``` +Encryption key got corrupted, please remove the integration and re-add it +``` + +It means that encryption key was modified from outside the code, +Please remove the integration and re-add it to make it work again. + +## Components + +#### Binary Sensors +Binary sensor are relying on MQTT, you will need to set up in Shinobi Video Server MQTT plugin and configure each of the monitors to trigger MQTT message. + +Each binary sensor will have the name pattern - {Integration Title} {Camera Name} {Sound / Motion}, +Once triggered, the following details will be added to the attributes of the binary sensor: + +Attributes | Description | +--- | --- | +name | Event name - Yolo / audio +reason | Event details - object / soundChange +tags | relevant for motion only with object detection, will represent the detected object + + +###### Audio +Represents whether the camera is triggered for noise or not + +###### Motion +Represents whether the camera is triggered for motion or not + +###### Camera +State: Idle + +Attributes | Available values | +--- | --- | +Status | Recording, +Mode | stop (Disabled), start (Watch-Only), record (Record) +Type | H264, MJPEG, +FPS | - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc92ff9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +target-version = ["py38", "py39"] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b98bb3d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,40 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +# F405 +# F403 +ignore = + E501, + W503, + E203, + D202, + W504, + F405, + F403, + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = homeassistant,tests +forced_separate = tests +combine_as_imports = true \ No newline at end of file