-
-
Notifications
You must be signed in to change notification settings - Fork 38.3k
Add config flow for Vivotek integration #154801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
229a399
3133616
f26ba03
85aed59
dd741d4
8d51338
7bb1fd5
92e6737
fa31269
9dd00f6
8f552f2
0c1c473
a7f81b0
b19cb57
bb89459
ee1b19a
df8f876
fb6f9b5
bd4ae4b
db140f3
8aaaba2
1d1c8fa
99ac290
7bc7c41
6caf3f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,91 @@ | ||
| """The Vivotek camera component.""" | ||
|
|
||
| from dataclasses import dataclass | ||
| import logging | ||
| from types import MappingProxyType | ||
| from typing import Any, TypedDict | ||
|
|
||
| from libpyvivotek import VivotekCamera | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import ( | ||
| CONF_AUTHENTICATION, | ||
| CONF_IP_ADDRESS, | ||
| CONF_PASSWORD, | ||
| CONF_PORT, | ||
| CONF_USERNAME, | ||
| CONF_VERIFY_SSL, | ||
| HTTP_DIGEST_AUTHENTICATION, | ||
| Platform, | ||
| ) | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .const import CONF_SECURITY_LEVEL | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| PLATFORMS = [Platform.CAMERA] | ||
|
|
||
| type VivotekConfigEntry = config_entries.ConfigEntry[VivotekData] | ||
|
|
||
|
|
||
| class VivotekCameraConf(TypedDict): | ||
| """Vivotek Camera configuration type.""" | ||
|
|
||
| authentication: str | ||
| ip_address: str | ||
| password: str | ||
| port: int | ||
| security_level: str | ||
| ssl: bool | ||
| username: str | ||
| verify_ssl: bool | ||
|
|
||
|
|
||
| async def async_build_and_test_cam_client( | ||
| hass: HomeAssistant, | ||
| data: dict[str, Any] | MappingProxyType[str, Any] | VivotekCameraConf, | ||
| ) -> VivotekCamera: | ||
| """Build the client and test if the provided configuration is valid.""" | ||
| cam_client = VivotekCamera( | ||
| host=data[CONF_IP_ADDRESS], | ||
| port=data[CONF_PORT], | ||
| verify_ssl=data[CONF_VERIFY_SSL], | ||
| usr=data[CONF_USERNAME], | ||
| pwd=data[CONF_PASSWORD], | ||
| digest_auth=(data[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION), | ||
| sec_lvl=data[CONF_SECURITY_LEVEL], # type: ignore[literal-required] | ||
| ) | ||
| mac = await hass.async_add_executor_job(cam_client.get_mac) | ||
| assert len(mac) > 0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we have this assert? What happens when this is the case?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was using it as a way to fail faster but can remove it |
||
| return cam_client | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: VivotekConfigEntry) -> bool: | ||
| """Set up the Vivotek component from a config entry.""" | ||
|
|
||
| try: | ||
| cam_client = await async_build_and_test_cam_client(hass, entry.data) | ||
| entry.runtime_data = VivotekData( | ||
| cam_client=cam_client, | ||
| ) | ||
| except Exception: | ||
| _LOGGER.exception("Unexpected exception during setup") | ||
| return False | ||
|
HarlemSquirrel marked this conversation as resolved.
Outdated
|
||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
|
HarlemSquirrel marked this conversation as resolved.
Outdated
|
||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
|
|
||
|
|
||
| @dataclass | ||
| class VivotekData: | ||
| """Data for the Vivotek component.""" | ||
|
|
||
| cam_client: VivotekCamera | ||
|
HarlemSquirrel marked this conversation as resolved.
Outdated
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,10 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from types import MappingProxyType | ||
| from typing import Any | ||
|
|
||
| from libpyvivotek import VivotekCamera | ||
| import voluptuous as vol | ||
|
|
||
|
|
@@ -22,20 +26,32 @@ | |
| HTTP_DIGEST_AUTHENTICATION, | ||
| ) | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers import config_validation as cv | ||
| from homeassistant.helpers.entity_platform import AddEntitiesCallback | ||
| from homeassistant.helpers import config_validation as cv, device_registry as dr | ||
| from homeassistant.helpers.device_registry import DeviceInfo, format_mac | ||
| from homeassistant.helpers.entity_platform import ( | ||
| AddConfigEntryEntitiesCallback, | ||
| AddEntitiesCallback, | ||
| ) | ||
| from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType | ||
|
|
||
| CONF_FRAMERATE = "framerate" | ||
| CONF_SECURITY_LEVEL = "security_level" | ||
| CONF_STREAM_PATH = "stream_path" | ||
| from . import VivotekConfigEntry | ||
| from .const import ( | ||
| CONF_FRAMERATE, | ||
| CONF_SECURITY_LEVEL, | ||
| CONF_STREAM_PATH, | ||
| DOMAIN, | ||
| MANUFACTURER, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| DEFAULT_CAMERA_BRAND = "VIVOTEK" | ||
| DEFAULT_NAME = "VIVOTEK Camera" | ||
| DEFAULT_EVENT_0_KEY = "event_i0_enable" | ||
| DEFAULT_SECURITY_LEVEL = "admin" | ||
| DEFAULT_STREAM_SOURCE = "live.sdp" | ||
|
|
||
|
|
||
| PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( | ||
| { | ||
| vol.Required(CONF_IP_ADDRESS): cv.string, | ||
|
|
@@ -54,13 +70,43 @@ | |
| ) | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, | ||
| entry: VivotekConfigEntry, | ||
| async_add_entities: AddConfigEntryEntitiesCallback, | ||
| ) -> None: | ||
| """Set up the component from a config entry.""" | ||
| config = entry.data | ||
| creds = f"{config[CONF_USERNAME]}:{config[CONF_PASSWORD]}" | ||
| stream_source = ( | ||
| f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" | ||
| ) | ||
| cam_client = entry.runtime_data.cam_client | ||
| mac_address = await hass.async_add_executor_job(cam_client.get_mac) | ||
| unique_id = format_mac(mac_address) | ||
| device_registry = dr.async_get(hass) | ||
| device_registry.async_get_or_create( | ||
| config_entry_id=entry.entry_id, | ||
| identifiers={(DOMAIN, unique_id)}, | ||
| connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, | ||
| ) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not suite sure why we add a device here. IMO the config flow PR should leave the existing entity alone and just change the configuration source. We can always improve the class later |
||
|
|
||
| if not entry.unique_id: | ||
| hass.config_entries.async_update_entry( | ||
| entry, | ||
| unique_id=unique_id, | ||
| title=str(config[CONF_NAME]), | ||
| ) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean we can make sure we have an entry during the config flow.
|
||
| async_add_entities([VivotekCam(entry.data, cam_client, stream_source, unique_id)]) | ||
|
|
||
|
|
||
| def setup_platform( | ||
| hass: HomeAssistant, | ||
| config: ConfigType, | ||
| add_entities: AddEntitiesCallback, | ||
| discovery_info: DiscoveryInfoType | None = None, | ||
| ) -> None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The existing YAML configuration should start an import flow so the user only has to remove the YAML config and then they are fully migrated to the UI. Check |
||
| """Set up a Vivotek IP Camera.""" | ||
| """Set up a Vivotek IP Camera from Yaml.""" | ||
| creds = f"{config[CONF_USERNAME]}:{config[CONF_PASSWORD]}" | ||
| cam = VivotekCamera( | ||
| host=config[CONF_IP_ADDRESS], | ||
|
|
@@ -74,46 +120,102 @@ def setup_platform( | |
| stream_source = ( | ||
| f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" | ||
| ) | ||
| add_entities([VivotekCam(config, cam, stream_source)], True) | ||
| unique_id = cam.get_mac() | ||
| add_entities([VivotekCam(config, cam, stream_source, unique_id)], True) | ||
|
|
||
|
|
||
| class VivotekCam(Camera): | ||
| """A Vivotek IP camera.""" | ||
|
|
||
| _attr_available = False | ||
| _attr_brand = DEFAULT_CAMERA_BRAND | ||
| _attr_configuration_url: str | None = None | ||
| _attr_model: str | None = None | ||
| _attr_serial: str | None = None | ||
|
HarlemSquirrel marked this conversation as resolved.
Outdated
|
||
| _attr_supported_features = CameraEntityFeature.STREAM | ||
| _attr_unique_id = "" | ||
|
HarlemSquirrel marked this conversation as resolved.
Outdated
|
||
|
|
||
| def __init__( | ||
| self, config: ConfigType, cam: VivotekCamera, stream_source: str | ||
| self, | ||
| config: ConfigType | MappingProxyType[str, Any], | ||
| cam_client: VivotekCamera, | ||
| stream_source: str, | ||
| unique_id: str, | ||
| ) -> None: | ||
| """Initialize a Vivotek camera.""" | ||
| super().__init__() | ||
|
|
||
| self._cam = cam | ||
| self._cam_client = cam_client | ||
| self._attr_configuration_url = f"http://{config[CONF_IP_ADDRESS]}" | ||
| self._attr_frame_interval = 1 / config[CONF_FRAMERATE] | ||
| self._attr_name = config[CONF_NAME] | ||
| self._attr_unique_id = unique_id | ||
| self._stream_source = stream_source | ||
|
|
||
| def camera_image( | ||
| self, width: int | None = None, height: int | None = None | ||
| ) -> bytes | None: | ||
| """Return bytes of camera image.""" | ||
| return self._cam.snapshot() | ||
| return self._cam_client.snapshot() | ||
|
|
||
| async def stream_source(self) -> str: | ||
| """Return the source of the stream.""" | ||
| return self._stream_source | ||
|
|
||
| def disable_motion_detection(self) -> None: | ||
| """Disable motion detection in camera.""" | ||
| response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 0) | ||
| response = self._cam_client.set_param(DEFAULT_EVENT_0_KEY, 0) | ||
| self._attr_motion_detection_enabled = int(response) == 1 | ||
|
|
||
| def enable_motion_detection(self) -> None: | ||
| """Enable motion detection in camera.""" | ||
| response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 1) | ||
| response = self._cam_client.set_param(DEFAULT_EVENT_0_KEY, 1) | ||
| self._attr_motion_detection_enabled = int(response) == 1 | ||
|
|
||
| def update(self) -> None: | ||
| """Update entity status.""" | ||
| self._attr_model = self._cam.model_name | ||
| self._attr_model = self._cam_client.model_name | ||
| self._attr_available = self._attr_model is not None | ||
| self._attr_serial = self._cam_client.get_serial() | ||
|
|
||
| async def async_update(self) -> None: | ||
| """Update the entity.""" | ||
| await self.hass.async_add_executor_job(self.update) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this is even the default implementation of async_update |
||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return True if entity is available.""" | ||
| return self._attr_available | ||
|
|
||
| @property | ||
| def brand(self) -> str | None: | ||
| """Return the camera brand.""" | ||
| return self._attr_brand | ||
|
|
||
| @property | ||
| def configuration_url(self) -> str | None: | ||
| """Return the configuration URL for the camera.""" | ||
| return self._attr_configuration_url | ||
|
|
||
| @property | ||
| def model(self) -> str | None: | ||
| """Return the camera model.""" | ||
| if self._attr_model is None: | ||
| self._attr_model = self._cam_client.model_name | ||
| return self._attr_model | ||
|
|
||
| @property | ||
| def name(self) -> str | None: | ||
| """Return the name of this device.""" | ||
| return self._attr_name | ||
|
|
||
| @property | ||
| def device_info(self) -> DeviceInfo | None: | ||
| """Return the device info.""" | ||
| return DeviceInfo( | ||
| configuration_url=self._attr_configuration_url, | ||
| identifiers={(DOMAIN, self._attr_unique_id)}, | ||
| manufacturer=MANUFACTURER, | ||
| model=self._attr_model, | ||
| name=self._attr_name, | ||
| serial_number=self._attr_unique_id, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.