Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
229a399
Add config flow for Vivotek integration
HarlemSquirrel Oct 19, 2025
3133616
Update homeassistant/components/vivotek/__init__.py
HarlemSquirrel Oct 19, 2025
f26ba03
Update homeassistant/components/vivotek/config_flow.py
HarlemSquirrel Oct 19, 2025
85aed59
Add config flow tests for vivotek
HarlemSquirrel Oct 19, 2025
dd741d4
Use port from config
HarlemSquirrel Oct 19, 2025
8d51338
Update requirements
HarlemSquirrel Oct 20, 2025
7bb1fd5
Add device info
HarlemSquirrel Oct 27, 2025
92e6737
Code review fixes
HarlemSquirrel Oct 27, 2025
fa31269
Merge branch 'dev' into vivotek-add-config-flow
HarlemSquirrel Oct 30, 2025
9dd00f6
Use add_suggested_values_to_schema and fix tests
HarlemSquirrel Oct 30, 2025
8f552f2
Remove unused instance var
HarlemSquirrel Oct 30, 2025
0c1c473
Format with prettier
HarlemSquirrel Oct 30, 2025
a7f81b0
fixup! Format with prettier
HarlemSquirrel Oct 30, 2025
b19cb57
Fix security level default config
HarlemSquirrel Oct 30, 2025
bb89459
Code review updates and remove some uneccessary code
HarlemSquirrel Oct 30, 2025
ee1b19a
Updates from code review
HarlemSquirrel Nov 19, 2025
df8f876
Update tests
HarlemSquirrel Nov 19, 2025
fb6f9b5
Update yaml import
HarlemSquirrel Nov 19, 2025
bd4ae4b
Put back plat schema
HarlemSquirrel Nov 19, 2025
db140f3
Move framerate to options
HarlemSquirrel Nov 19, 2025
8aaaba2
Update homeassistant/components/vivotek/config_flow.py
HarlemSquirrel Nov 19, 2025
1d1c8fa
Create deprecation issue for config flow change
HarlemSquirrel Nov 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions homeassistant/components/vivotek/__init__.py
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
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""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
102 changes: 88 additions & 14 deletions homeassistant/components/vivotek/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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)},
)
Copy link
Member

Choose a reason for hiding this comment

The 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]),
)
Comment on lines 90 to 94
Copy link
Member

Choose a reason for hiding this comment

The 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.

title is considered userland, we shouldn't change that

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:
Copy link
Member

Choose a reason for hiding this comment

The 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 nederlandse_spoorwegen for an example

"""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],
Expand All @@ -74,46 +120,74 @@ 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."""

# Overrides from Camera
_attr_brand = DEFAULT_CAMERA_BRAND
_attr_supported_features = CameraEntityFeature.STREAM

_attr_configuration_url: str | None = None
_attr_serial: str | None = None

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)
Copy link
Member

Choose a reason for hiding this comment

The 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 device_info(self) -> DeviceInfo | None:
"""Return the device info."""
return DeviceInfo(
configuration_url=self._attr_configuration_url,
identifiers={(DOMAIN, self._attr_unique_id or "")},
manufacturer=MANUFACTURER,
model=self._attr_model,
name=self._attr_name,
serial_number=self._attr_unique_id,
)
Loading
Loading