Streamline naming in the Axis integration (#112044)

* Rename device.py to hub.py

* Rename AxisNetworkDevice to AxisHub

* Move hub.py into hub package

* Rename get_axis_device to get_axis_api

* Split out get_axis_api to its own module

* Rename device object to hub

* Rename device to api in config flow

* Convenience method to get hub
This commit is contained in:
Robert Svensson 2024-03-02 17:32:51 +01:00 committed by GitHub
parent 196089e8b7
commit ece5587e1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 183 additions and 172 deletions

View File

@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN as AXIS_DOMAIN, PLATFORMS from .const import DOMAIN as AXIS_DOMAIN, PLATFORMS
from .device import AxisNetworkDevice, get_axis_device
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -18,21 +18,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
hass.data.setdefault(AXIS_DOMAIN, {}) hass.data.setdefault(AXIS_DOMAIN, {})
try: try:
api = await get_axis_device(hass, config_entry.data) api = await get_axis_api(hass, config_entry.data)
except CannotConnect as err: except CannotConnect as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except AuthenticationRequired as err: except AuthenticationRequired as err:
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
device = AxisNetworkDevice(hass, config_entry, api) hub = AxisHub(hass, config_entry, api)
hass.data[AXIS_DOMAIN][config_entry.entry_id] = device hass.data[AXIS_DOMAIN][config_entry.entry_id] = hub
await device.async_update_device_registry() await hub.async_update_device_registry()
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
device.async_setup_events() hub.async_setup_events()
config_entry.add_update_listener(device.async_new_address_callback) config_entry.add_update_listener(hub.async_new_address_callback)
config_entry.async_on_unload( config_entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown)
) )
return True return True
@ -40,8 +40,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload Axis device config entry.""" """Unload Axis device config entry."""
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN].pop(config_entry.entry_id) hub: AxisHub = hass.data[AXIS_DOMAIN].pop(config_entry.entry_id)
return await device.async_reset() return await hub.async_reset()
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:

View File

@ -19,9 +19,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from .const import DOMAIN as AXIS_DOMAIN
from .device import AxisNetworkDevice
from .entity import AxisEventEntity from .entity import AxisEventEntity
from .hub import AxisHub
DEVICE_CLASS = { DEVICE_CLASS = {
EventGroup.INPUT: BinarySensorDeviceClass.CONNECTIVITY, EventGroup.INPUT: BinarySensorDeviceClass.CONNECTIVITY,
@ -52,14 +51,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up a Axis binary sensor.""" """Set up a Axis binary sensor."""
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
@callback @callback
def async_create_entity(event: Event) -> None: def async_create_entity(event: Event) -> None:
"""Create Axis binary sensor entity.""" """Create Axis binary sensor entity."""
async_add_entities([AxisBinarySensor(event, device)]) async_add_entities([AxisBinarySensor(event, hub)])
device.api.event.subscribe( hub.api.event.subscribe(
async_create_entity, async_create_entity,
topic_filter=EVENT_TOPICS, topic_filter=EVENT_TOPICS,
operation_filter=EventOperation.INITIALIZED, operation_filter=EventOperation.INITIALIZED,
@ -69,9 +68,9 @@ async def async_setup_entry(
class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): class AxisBinarySensor(AxisEventEntity, BinarySensorEntity):
"""Representation of a binary Axis event.""" """Representation of a binary Axis event."""
def __init__(self, event: Event, device: AxisNetworkDevice) -> None: def __init__(self, event: Event, hub: AxisHub) -> None:
"""Initialize the Axis binary sensor.""" """Initialize the Axis binary sensor."""
super().__init__(event, device) super().__init__(event, hub)
self.cancel_scheduled_update: Callable[[], None] | None = None self.cancel_scheduled_update: Callable[[], None] | None = None
self._attr_device_class = DEVICE_CLASS.get(event.group) self._attr_device_class = DEVICE_CLASS.get(event.group)
@ -94,13 +93,13 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity):
self.cancel_scheduled_update() self.cancel_scheduled_update()
self.cancel_scheduled_update = None self.cancel_scheduled_update = None
if self.is_on or self.device.option_trigger_time == 0: if self.is_on or self.hub.option_trigger_time == 0:
self.async_write_ha_state() self.async_write_ha_state()
return return
self.cancel_scheduled_update = async_call_later( self.cancel_scheduled_update = async_call_later(
self.hass, self.hass,
timedelta(seconds=self.device.option_trigger_time), timedelta(seconds=self.hub.option_trigger_time),
scheduled_update, scheduled_update,
) )
@ -109,21 +108,21 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity):
"""Set binary sensor name.""" """Set binary sensor name."""
if ( if (
event.group == EventGroup.INPUT event.group == EventGroup.INPUT
and event.id in self.device.api.vapix.ports and event.id in self.hub.api.vapix.ports
and self.device.api.vapix.ports[event.id].name and self.hub.api.vapix.ports[event.id].name
): ):
self._attr_name = self.device.api.vapix.ports[event.id].name self._attr_name = self.hub.api.vapix.ports[event.id].name
elif event.group == EventGroup.MOTION: elif event.group == EventGroup.MOTION:
event_data: FenceGuardHandler | LoiteringGuardHandler | MotionGuardHandler | Vmd4Handler | None = None event_data: FenceGuardHandler | LoiteringGuardHandler | MotionGuardHandler | Vmd4Handler | None = None
if event.topic_base == EventTopic.FENCE_GUARD: if event.topic_base == EventTopic.FENCE_GUARD:
event_data = self.device.api.vapix.fence_guard event_data = self.hub.api.vapix.fence_guard
elif event.topic_base == EventTopic.LOITERING_GUARD: elif event.topic_base == EventTopic.LOITERING_GUARD:
event_data = self.device.api.vapix.loitering_guard event_data = self.hub.api.vapix.loitering_guard
elif event.topic_base == EventTopic.MOTION_GUARD: elif event.topic_base == EventTopic.MOTION_GUARD:
event_data = self.device.api.vapix.motion_guard event_data = self.hub.api.vapix.motion_guard
elif event.topic_base == EventTopic.MOTION_DETECTION_4: elif event.topic_base == EventTopic.MOTION_DETECTION_4:
event_data = self.device.api.vapix.vmd4 event_data = self.hub.api.vapix.vmd4
if ( if (
event_data event_data
and event_data.initialized and event_data.initialized
@ -137,8 +136,8 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity):
if ( if (
event.topic_base == EventTopic.OBJECT_ANALYTICS event.topic_base == EventTopic.OBJECT_ANALYTICS
and self.device.api.vapix.object_analytics.initialized and self.hub.api.vapix.object_analytics.initialized
and (scenarios := self.device.api.vapix.object_analytics["0"].scenarios) and (scenarios := self.hub.api.vapix.object_analytics["0"].scenarios)
): ):
for scenario_id, scenario in scenarios.items(): for scenario_id, scenario in scenarios.items():
device_id = scenario.devices[0]["id"] device_id = scenario.devices[0]["id"]

View File

@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE, DOMAIN as AXIS_DOMAIN from .const import DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE
from .device import AxisNetworkDevice
from .entity import AxisEntity from .entity import AxisEntity
from .hub import AxisHub
async def async_setup_entry( async def async_setup_entry(
@ -22,15 +22,15 @@ async def async_setup_entry(
"""Set up the Axis camera video stream.""" """Set up the Axis camera video stream."""
filter_urllib3_logging() filter_urllib3_logging()
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
if ( if (
not (prop := device.api.vapix.params.property_handler.get("0")) not (prop := hub.api.vapix.params.property_handler.get("0"))
or not prop.image_format or not prop.image_format
): ):
return return
async_add_entities([AxisCamera(device)]) async_add_entities([AxisCamera(hub)])
class AxisCamera(AxisEntity, MjpegCamera): class AxisCamera(AxisEntity, MjpegCamera):
@ -42,27 +42,27 @@ class AxisCamera(AxisEntity, MjpegCamera):
_mjpeg_url: str _mjpeg_url: str
_stream_source: str _stream_source: str
def __init__(self, device: AxisNetworkDevice) -> None: def __init__(self, hub: AxisHub) -> None:
"""Initialize Axis Communications camera component.""" """Initialize Axis Communications camera component."""
AxisEntity.__init__(self, device) AxisEntity.__init__(self, hub)
self._generate_sources() self._generate_sources()
MjpegCamera.__init__( MjpegCamera.__init__(
self, self,
username=device.username, username=hub.username,
password=device.password, password=hub.password,
mjpeg_url=self.mjpeg_source, mjpeg_url=self.mjpeg_source,
still_image_url=self.image_source, still_image_url=self.image_source,
authentication=HTTP_DIGEST_AUTHENTICATION, authentication=HTTP_DIGEST_AUTHENTICATION,
unique_id=f"{device.unique_id}-camera", unique_id=f"{hub.unique_id}-camera",
) )
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Subscribe camera events.""" """Subscribe camera events."""
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.device.signal_new_address, self._generate_sources self.hass, self.hub.signal_new_address, self._generate_sources
) )
) )
@ -75,27 +75,27 @@ class AxisCamera(AxisEntity, MjpegCamera):
""" """
image_options = self.generate_options(skip_stream_profile=True) image_options = self.generate_options(skip_stream_profile=True)
self._still_image_url = ( self._still_image_url = (
f"http://{self.device.host}:{self.device.port}/axis-cgi" f"http://{self.hub.host}:{self.hub.port}/axis-cgi"
f"/jpg/image.cgi{image_options}" f"/jpg/image.cgi{image_options}"
) )
mjpeg_options = self.generate_options() mjpeg_options = self.generate_options()
self._mjpeg_url = ( self._mjpeg_url = (
f"http://{self.device.host}:{self.device.port}/axis-cgi" f"http://{self.hub.host}:{self.hub.port}/axis-cgi"
f"/mjpg/video.cgi{mjpeg_options}" f"/mjpg/video.cgi{mjpeg_options}"
) )
stream_options = self.generate_options(add_video_codec_h264=True) stream_options = self.generate_options(add_video_codec_h264=True)
self._stream_source = ( self._stream_source = (
f"rtsp://{self.device.username}:{self.device.password}" f"rtsp://{self.hub.username}:{self.hub.password}"
f"@{self.device.host}/axis-media/media.amp{stream_options}" f"@{self.hub.host}/axis-media/media.amp{stream_options}"
) )
self.device.additional_diagnostics["camera_sources"] = { self.hub.additional_diagnostics["camera_sources"] = {
"Image": self._still_image_url, "Image": self._still_image_url,
"MJPEG": self._mjpeg_url, "MJPEG": self._mjpeg_url,
"Stream": ( "Stream": (
f"rtsp://user:pass@{self.device.host}/axis-media" f"rtsp://user:pass@{self.hub.host}/axis-media"
f"/media.amp{stream_options}" f"/media.amp{stream_options}"
), ),
} }
@ -125,12 +125,12 @@ class AxisCamera(AxisEntity, MjpegCamera):
if ( if (
not skip_stream_profile not skip_stream_profile
and self.device.option_stream_profile != DEFAULT_STREAM_PROFILE and self.hub.option_stream_profile != DEFAULT_STREAM_PROFILE
): ):
options_dict["streamprofile"] = self.device.option_stream_profile options_dict["streamprofile"] = self.hub.option_stream_profile
if self.device.option_video_source != DEFAULT_VIDEO_SOURCE: if self.hub.option_video_source != DEFAULT_VIDEO_SOURCE:
options_dict["camera"] = self.device.option_video_source options_dict["camera"] = self.hub.option_video_source
if not options_dict: if not options_dict:
return "" return ""

View File

@ -37,8 +37,8 @@ from .const import (
DEFAULT_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE,
DOMAIN as AXIS_DOMAIN, DOMAIN as AXIS_DOMAIN,
) )
from .device import AxisNetworkDevice, get_axis_device
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"} AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
DEFAULT_PORT = 80 DEFAULT_PORT = 80
@ -71,9 +71,9 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
device = await get_axis_device(self.hass, MappingProxyType(user_input)) api = await get_axis_api(self.hass, MappingProxyType(user_input))
serial = device.vapix.serial_number serial = api.vapix.serial_number
await self.async_set_unique_id(format_mac(serial)) await self.async_set_unique_id(format_mac(serial))
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
@ -90,7 +90,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
CONF_PORT: user_input[CONF_PORT], CONF_PORT: user_input[CONF_PORT],
CONF_USERNAME: user_input[CONF_USERNAME], CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_MODEL: device.vapix.product_number, CONF_MODEL: api.vapix.product_number,
} }
return await self._create_entry(serial) return await self._create_entry(serial)
@ -238,13 +238,13 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry): class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle Axis device options.""" """Handle Axis device options."""
device: AxisNetworkDevice hub: AxisHub
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the Axis device options.""" """Manage the Axis device options."""
self.device = self.hass.data[AXIS_DOMAIN][self.config_entry.entry_id] self.hub = AxisHub.get_hub(self.hass, self.config_entry)
return await self.async_step_configure_stream() return await self.async_step_configure_stream()
async def async_step_configure_stream( async def async_step_configure_stream(
@ -257,7 +257,7 @@ class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
schema = {} schema = {}
vapix = self.device.api.vapix vapix = self.hub.api.vapix
# Stream profiles # Stream profiles
@ -271,7 +271,7 @@ class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
schema[ schema[
vol.Optional( vol.Optional(
CONF_STREAM_PROFILE, default=self.device.option_stream_profile CONF_STREAM_PROFILE, default=self.hub.option_stream_profile
) )
] = vol.In(stream_profiles) ] = vol.In(stream_profiles)
@ -290,7 +290,7 @@ class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
video_sources[int(idx) + 1] = video_source.name video_sources[int(idx) + 1] = video_source.name
schema[ schema[
vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source) vol.Optional(CONF_VIDEO_SOURCE, default=self.hub.option_video_source)
] = vol.In(video_sources) ] = vol.In(video_sources)
return self.async_show_form( return self.async_show_form(

View File

@ -8,8 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.const import CONF_MAC, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN as AXIS_DOMAIN from .hub import AxisHub
from .device import AxisNetworkDevice
REDACT_CONFIG = {CONF_MAC, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME} REDACT_CONFIG = {CONF_MAC, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME}
REDACT_BASIC_DEVICE_INFO = {"SerialNumber", "SocSerialNumber"} REDACT_BASIC_DEVICE_INFO = {"SerialNumber", "SocSerialNumber"}
@ -20,26 +19,26 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
diag: dict[str, Any] = device.additional_diagnostics.copy() diag: dict[str, Any] = hub.additional_diagnostics.copy()
diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG) diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG)
if device.api.vapix.api_discovery: if hub.api.vapix.api_discovery:
diag["api_discovery"] = [ diag["api_discovery"] = [
{"id": api.id, "name": api.name, "version": api.version} {"id": api.id, "name": api.name, "version": api.version}
for api in device.api.vapix.api_discovery.values() for api in hub.api.vapix.api_discovery.values()
] ]
if device.api.vapix.basic_device_info: if hub.api.vapix.basic_device_info:
diag["basic_device_info"] = async_redact_data( diag["basic_device_info"] = async_redact_data(
device.api.vapix.basic_device_info["0"], hub.api.vapix.basic_device_info["0"],
REDACT_BASIC_DEVICE_INFO, REDACT_BASIC_DEVICE_INFO,
) )
if device.api.vapix.params: if hub.api.vapix.params:
diag["params"] = async_redact_data( diag["params"] = async_redact_data(
device.api.vapix.params.items(), hub.api.vapix.params.items(),
REDACT_VAPIX_PARAMS, REDACT_VAPIX_PARAMS,
) )

View File

@ -10,7 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN as AXIS_DOMAIN from .const import DOMAIN as AXIS_DOMAIN
from .device import AxisNetworkDevice from .hub import AxisHub
TOPIC_TO_EVENT_TYPE = { TOPIC_TO_EVENT_TYPE = {
EventTopic.DAY_NIGHT_VISION: "DayNight", EventTopic.DAY_NIGHT_VISION: "DayNight",
@ -37,13 +37,13 @@ class AxisEntity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, device: AxisNetworkDevice) -> None: def __init__(self, hub: AxisHub) -> None:
"""Initialize the Axis event.""" """Initialize the Axis event."""
self.device = device self.hub = hub
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(AXIS_DOMAIN, device.unique_id)}, # type: ignore[arg-type] identifiers={(AXIS_DOMAIN, hub.unique_id)}, # type: ignore[arg-type]
serial_number=device.unique_id, serial_number=hub.unique_id,
) )
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@ -51,7 +51,7 @@ class AxisEntity(Entity):
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
self.device.signal_reachable, self.hub.signal_reachable,
self.async_signal_reachable_callback, self.async_signal_reachable_callback,
) )
) )
@ -59,7 +59,7 @@ class AxisEntity(Entity):
@callback @callback
def async_signal_reachable_callback(self) -> None: def async_signal_reachable_callback(self) -> None:
"""Call when device connection state change.""" """Call when device connection state change."""
self._attr_available = self.device.available self._attr_available = self.hub.available
self.async_write_ha_state() self.async_write_ha_state()
@ -68,16 +68,16 @@ class AxisEventEntity(AxisEntity):
_attr_should_poll = False _attr_should_poll = False
def __init__(self, event: Event, device: AxisNetworkDevice) -> None: def __init__(self, event: Event, hub: AxisHub) -> None:
"""Initialize the Axis event.""" """Initialize the Axis event."""
super().__init__(device) super().__init__(hub)
self._event_id = event.id self._event_id = event.id
self._event_topic = event.topic_base self._event_topic = event.topic_base
self._event_type = TOPIC_TO_EVENT_TYPE[event.topic_base] self._event_type = TOPIC_TO_EVENT_TYPE[event.topic_base]
self._attr_name = f"{self._event_type} {event.id}" self._attr_name = f"{self._event_type} {event.id}"
self._attr_unique_id = f"{device.unique_id}-{event.topic}-{event.id}" self._attr_unique_id = f"{hub.unique_id}-{event.topic}-{event.id}"
self._attr_device_class = event.group.value self._attr_device_class = event.group.value
@ -90,7 +90,7 @@ class AxisEventEntity(AxisEntity):
"""Subscribe sensors events.""" """Subscribe sensors events."""
await super().async_added_to_hass() await super().async_added_to_hass()
self.async_on_remove( self.async_on_remove(
self.device.api.event.subscribe( self.hub.api.event.subscribe(
self.async_event_callback, self.async_event_callback,
id_filter=self._event_id, id_filter=self._event_id,
topic_filter=self._event_topic, topic_filter=self._event_topic,

View File

@ -0,0 +1,4 @@
"""Internal functionality not part of HA infrastructure."""
from .api import get_axis_api # noqa: F401
from .hub import AxisHub # noqa: F401

View File

@ -0,0 +1,53 @@
"""Axis network device abstraction."""
from asyncio import timeout
from types import MappingProxyType
from typing import Any
import axis
from axis.configuration import Configuration
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from ..const import LOGGER
from ..errors import AuthenticationRequired, CannotConnect
async def get_axis_api(
hass: HomeAssistant,
config: MappingProxyType[str, Any],
) -> axis.AxisDevice:
"""Create a Axis device API."""
session = get_async_client(hass, verify_ssl=False)
device = axis.AxisDevice(
Configuration(
session,
config[CONF_HOST],
port=config[CONF_PORT],
username=config[CONF_USERNAME],
password=config[CONF_PASSWORD],
)
)
try:
async with timeout(30):
await device.vapix.initialize()
return device
except axis.Unauthorized as err:
LOGGER.warning(
"Connected to device at %s but not registered", config[CONF_HOST]
)
raise AuthenticationRequired from err
except (TimeoutError, axis.RequestError) as err:
LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST])
raise CannotConnect from err
except axis.AxisException as err:
LOGGER.exception("Unknown Axis communication error occurred")
raise AuthenticationRequired from err

View File

@ -1,11 +1,10 @@
"""Axis network device abstraction.""" """Axis network device abstraction."""
from asyncio import timeout from __future__ import annotations
from types import MappingProxyType
from typing import Any from typing import Any
import axis import axis
from axis.configuration import Configuration
from axis.errors import Unauthorized from axis.errors import Unauthorized
from axis.stream_manager import Signal, State from axis.stream_manager import Signal, State
from axis.vapix.interfaces.mqtt import mqtt_json_to_event from axis.vapix.interfaces.mqtt import mqtt_json_to_event
@ -28,10 +27,9 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.setup import async_when_setup from homeassistant.setup import async_when_setup
from .const import ( from ..const import (
ATTR_MANUFACTURER, ATTR_MANUFACTURER,
CONF_EVENTS, CONF_EVENTS,
CONF_STREAM_PROFILE, CONF_STREAM_PROFILE,
@ -41,13 +39,11 @@ from .const import (
DEFAULT_TRIGGER_TIME, DEFAULT_TRIGGER_TIME,
DEFAULT_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE,
DOMAIN as AXIS_DOMAIN, DOMAIN as AXIS_DOMAIN,
LOGGER,
PLATFORMS, PLATFORMS,
) )
from .errors import AuthenticationRequired, CannotConnect
class AxisNetworkDevice: class AxisHub:
"""Manages a Axis device.""" """Manages a Axis device."""
def __init__( def __init__(
@ -64,6 +60,13 @@ class AxisNetworkDevice:
self.additional_diagnostics: dict[str, Any] = {} self.additional_diagnostics: dict[str, Any] = {}
@callback
@staticmethod
def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> AxisHub:
"""Get Axis hub from config entry."""
hub: AxisHub = hass.data[AXIS_DOMAIN][config_entry.entry_id]
return hub
@property @property
def host(self) -> str: def host(self) -> str:
"""Return the host address of this device.""" """Return the host address of this device."""
@ -157,7 +160,7 @@ class AxisNetworkDevice:
@staticmethod @staticmethod
async def async_new_address_callback( async def async_new_address_callback(
hass: HomeAssistant, entry: ConfigEntry hass: HomeAssistant, config_entry: ConfigEntry
) -> None: ) -> None:
"""Handle signals of device getting new address. """Handle signals of device getting new address.
@ -165,9 +168,9 @@ class AxisNetworkDevice:
This is a static method because a class method (bound method), This is a static method because a class method (bound method),
cannot be used with weak references. cannot be used with weak references.
""" """
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
device.api.config.host = device.host hub.api.config.host = hub.host
async_dispatcher_send(hass, device.signal_new_address) async_dispatcher_send(hass, hub.signal_new_address)
async def async_update_device_registry(self) -> None: async def async_update_device_registry(self) -> None:
"""Update device registry.""" """Update device registry."""
@ -237,41 +240,3 @@ class AxisNetworkDevice:
return await self.hass.config_entries.async_unload_platforms( return await self.hass.config_entries.async_unload_platforms(
self.config_entry, PLATFORMS self.config_entry, PLATFORMS
) )
async def get_axis_device(
hass: HomeAssistant,
config: MappingProxyType[str, Any],
) -> axis.AxisDevice:
"""Create a Axis device."""
session = get_async_client(hass, verify_ssl=False)
device = axis.AxisDevice(
Configuration(
session,
config[CONF_HOST],
port=config[CONF_PORT],
username=config[CONF_USERNAME],
password=config[CONF_PASSWORD],
)
)
try:
async with timeout(30):
await device.vapix.initialize()
return device
except axis.Unauthorized as err:
LOGGER.warning(
"Connected to device at %s but not registered", config[CONF_HOST]
)
raise AuthenticationRequired from err
except (TimeoutError, axis.RequestError) as err:
LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST])
raise CannotConnect from err
except axis.AxisException as err:
LOGGER.exception("Unknown Axis communication error occurred")
raise AuthenticationRequired from err

View File

@ -8,9 +8,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as AXIS_DOMAIN
from .device import AxisNetworkDevice
from .entity import AxisEventEntity from .entity import AxisEventEntity
from .hub import AxisHub
async def async_setup_entry( async def async_setup_entry(
@ -19,20 +18,17 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up a Axis light.""" """Set up a Axis light."""
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
if ( if hub.api.vapix.light_control is None or len(hub.api.vapix.light_control) == 0:
device.api.vapix.light_control is None
or len(device.api.vapix.light_control) == 0
):
return return
@callback @callback
def async_create_entity(event: Event) -> None: def async_create_entity(event: Event) -> None:
"""Create Axis light entity.""" """Create Axis light entity."""
async_add_entities([AxisLight(event, device)]) async_add_entities([AxisLight(event, hub)])
device.api.event.subscribe( hub.api.event.subscribe(
async_create_entity, async_create_entity,
topic_filter=EventTopic.LIGHT_STATUS, topic_filter=EventTopic.LIGHT_STATUS,
operation_filter=EventOperation.INITIALIZED, operation_filter=EventOperation.INITIALIZED,
@ -44,16 +40,16 @@ class AxisLight(AxisEventEntity, LightEntity):
_attr_should_poll = True _attr_should_poll = True
def __init__(self, event: Event, device: AxisNetworkDevice) -> None: def __init__(self, event: Event, hub: AxisHub) -> None:
"""Initialize the Axis light.""" """Initialize the Axis light."""
super().__init__(event, device) super().__init__(event, hub)
self._light_id = f"led{event.id}" self._light_id = f"led{event.id}"
self.current_intensity = 0 self.current_intensity = 0
self.max_intensity = 0 self.max_intensity = 0
light_type = device.api.vapix.light_control[self._light_id].light_type light_type = hub.api.vapix.light_control[self._light_id].light_type
self._attr_name = f"{light_type} {self._event_type} {event.id}" self._attr_name = f"{light_type} {self._event_type} {event.id}"
self._attr_is_on = event.is_tripped self._attr_is_on = event.is_tripped
@ -65,13 +61,11 @@ class AxisLight(AxisEventEntity, LightEntity):
await super().async_added_to_hass() await super().async_added_to_hass()
current_intensity = ( current_intensity = (
await self.device.api.vapix.light_control.get_current_intensity( await self.hub.api.vapix.light_control.get_current_intensity(self._light_id)
self._light_id
)
) )
self.current_intensity = current_intensity self.current_intensity = current_intensity
max_intensity = await self.device.api.vapix.light_control.get_valid_intensity( max_intensity = await self.hub.api.vapix.light_control.get_valid_intensity(
self._light_id self._light_id
) )
self.max_intensity = max_intensity.high self.max_intensity = max_intensity.high
@ -90,24 +84,22 @@ class AxisLight(AxisEventEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light.""" """Turn on light."""
if not self.is_on: if not self.is_on:
await self.device.api.vapix.light_control.activate_light(self._light_id) await self.hub.api.vapix.light_control.activate_light(self._light_id)
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
intensity = int((kwargs[ATTR_BRIGHTNESS] / 255) * self.max_intensity) intensity = int((kwargs[ATTR_BRIGHTNESS] / 255) * self.max_intensity)
await self.device.api.vapix.light_control.set_manual_intensity( await self.hub.api.vapix.light_control.set_manual_intensity(
self._light_id, intensity self._light_id, intensity
) )
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light.""" """Turn off light."""
if self.is_on: if self.is_on:
await self.device.api.vapix.light_control.deactivate_light(self._light_id) await self.hub.api.vapix.light_control.deactivate_light(self._light_id)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update brightness.""" """Update brightness."""
current_intensity = ( current_intensity = (
await self.device.api.vapix.light_control.get_current_intensity( await self.hub.api.vapix.light_control.get_current_intensity(self._light_id)
self._light_id
)
) )
self.current_intensity = current_intensity self.current_intensity = current_intensity

View File

@ -8,9 +8,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as AXIS_DOMAIN
from .device import AxisNetworkDevice
from .entity import AxisEventEntity from .entity import AxisEventEntity
from .hub import AxisHub
async def async_setup_entry( async def async_setup_entry(
@ -19,14 +18,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up a Axis switch.""" """Set up a Axis switch."""
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] hub = AxisHub.get_hub(hass, config_entry)
@callback @callback
def async_create_entity(event: Event) -> None: def async_create_entity(event: Event) -> None:
"""Create Axis switch entity.""" """Create Axis switch entity."""
async_add_entities([AxisSwitch(event, device)]) async_add_entities([AxisSwitch(event, hub)])
device.api.event.subscribe( hub.api.event.subscribe(
async_create_entity, async_create_entity,
topic_filter=EventTopic.RELAY, topic_filter=EventTopic.RELAY,
operation_filter=EventOperation.INITIALIZED, operation_filter=EventOperation.INITIALIZED,
@ -36,11 +35,11 @@ async def async_setup_entry(
class AxisSwitch(AxisEventEntity, SwitchEntity): class AxisSwitch(AxisEventEntity, SwitchEntity):
"""Representation of a Axis switch.""" """Representation of a Axis switch."""
def __init__(self, event: Event, device: AxisNetworkDevice) -> None: def __init__(self, event: Event, hub: AxisHub) -> None:
"""Initialize the Axis switch.""" """Initialize the Axis switch."""
super().__init__(event, device) super().__init__(event, hub)
if event.id and device.api.vapix.ports[event.id].name: if event.id and hub.api.vapix.ports[event.id].name:
self._attr_name = device.api.vapix.ports[event.id].name self._attr_name = hub.api.vapix.ports[event.id].name
self._attr_is_on = event.is_tripped self._attr_is_on = event.is_tripped
@callback @callback
@ -51,8 +50,8 @@ class AxisSwitch(AxisEventEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch.""" """Turn on switch."""
await self.device.api.vapix.ports.close(self._event_id) await self.hub.api.vapix.ports.close(self._event_id)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch.""" """Turn off switch."""
await self.device.api.vapix.ports.open(self._event_id) await self.hub.api.vapix.ports.open(self._event_id)

View File

@ -124,7 +124,7 @@ async def test_flow_fails_faulty_credentials(hass: HomeAssistant) -> None:
assert result["step_id"] == "user" assert result["step_id"] == "user"
with patch( with patch(
"homeassistant.components.axis.config_flow.get_axis_device", "homeassistant.components.axis.config_flow.get_axis_api",
side_effect=config_flow.AuthenticationRequired, side_effect=config_flow.AuthenticationRequired,
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
@ -150,7 +150,7 @@ async def test_flow_fails_cannot_connect(hass: HomeAssistant) -> None:
assert result["step_id"] == "user" assert result["step_id"] == "user"
with patch( with patch(
"homeassistant.components.axis.config_flow.get_axis_device", "homeassistant.components.axis.config_flow.get_axis_api",
side_effect=config_flow.CannotConnect, side_effect=config_flow.CannotConnect,
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(

View File

@ -182,7 +182,7 @@ async def test_device_not_accessible(
hass: HomeAssistant, config_entry, setup_default_vapix_requests hass: HomeAssistant, config_entry, setup_default_vapix_requests
) -> None: ) -> None:
"""Failed setup schedules a retry of setup.""" """Failed setup schedules a retry of setup."""
with patch.object(axis, "get_axis_device", side_effect=axis.errors.CannotConnect): with patch.object(axis, "get_axis_api", side_effect=axis.errors.CannotConnect):
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.data[AXIS_DOMAIN] == {} assert hass.data[AXIS_DOMAIN] == {}
@ -193,7 +193,7 @@ async def test_device_trigger_reauth_flow(
) -> None: ) -> None:
"""Failed authentication trigger a reauthentication flow.""" """Failed authentication trigger a reauthentication flow."""
with patch.object( with patch.object(
axis, "get_axis_device", side_effect=axis.errors.AuthenticationRequired axis, "get_axis_api", side_effect=axis.errors.AuthenticationRequired
), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -205,7 +205,7 @@ async def test_device_unknown_error(
hass: HomeAssistant, config_entry, setup_default_vapix_requests hass: HomeAssistant, config_entry, setup_default_vapix_requests
) -> None: ) -> None:
"""Unknown errors are handled.""" """Unknown errors are handled."""
with patch.object(axis, "get_axis_device", side_effect=Exception): with patch.object(axis, "get_axis_api", side_effect=Exception):
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.data[AXIS_DOMAIN] == {} assert hass.data[AXIS_DOMAIN] == {}
@ -217,7 +217,7 @@ async def test_shutdown(config) -> None:
entry = Mock() entry = Mock()
entry.data = config entry.data = config
axis_device = axis.device.AxisNetworkDevice(hass, entry, Mock()) axis_device = axis.hub.AxisHub(hass, entry, Mock())
await axis_device.shutdown(None) await axis_device.shutdown(None)
@ -229,7 +229,7 @@ async def test_get_device_fails(hass: HomeAssistant, config) -> None:
with patch( with patch(
"axis.vapix.vapix.Vapix.initialize", side_effect=axislib.Unauthorized "axis.vapix.vapix.Vapix.initialize", side_effect=axislib.Unauthorized
), pytest.raises(axis.errors.AuthenticationRequired): ), pytest.raises(axis.errors.AuthenticationRequired):
await axis.device.get_axis_device(hass, config) await axis.hub.get_axis_api(hass, config)
async def test_get_device_device_unavailable(hass: HomeAssistant, config) -> None: async def test_get_device_device_unavailable(hass: HomeAssistant, config) -> None:
@ -237,7 +237,7 @@ async def test_get_device_device_unavailable(hass: HomeAssistant, config) -> Non
with patch( with patch(
"axis.vapix.vapix.Vapix.request", side_effect=axislib.RequestError "axis.vapix.vapix.Vapix.request", side_effect=axislib.RequestError
), pytest.raises(axis.errors.CannotConnect): ), pytest.raises(axis.errors.CannotConnect):
await axis.device.get_axis_device(hass, config) await axis.hub.get_axis_api(hass, config)
async def test_get_device_unknown_error(hass: HomeAssistant, config) -> None: async def test_get_device_unknown_error(hass: HomeAssistant, config) -> None:
@ -245,4 +245,4 @@ async def test_get_device_unknown_error(hass: HomeAssistant, config) -> None:
with patch( with patch(
"axis.vapix.vapix.Vapix.request", side_effect=axislib.AxisException "axis.vapix.vapix.Vapix.request", side_effect=axislib.AxisException
), pytest.raises(axis.errors.AuthenticationRequired): ), pytest.raises(axis.errors.AuthenticationRequired):
await axis.device.get_axis_device(hass, config) await axis.hub.get_axis_api(hass, config)

View File

@ -18,7 +18,7 @@ async def test_setup_entry_fails(hass: HomeAssistant, config_entry) -> None:
mock_device = Mock() mock_device = Mock()
mock_device.async_setup = AsyncMock(return_value=False) mock_device.async_setup = AsyncMock(return_value=False)
with patch.object(axis, "AxisNetworkDevice") as mock_device_class: with patch.object(axis, "AxisHub") as mock_device_class:
mock_device_class.return_value = mock_device mock_device_class.return_value = mock_device
assert not await hass.config_entries.async_setup(config_entry.entry_id) assert not await hass.config_entries.async_setup(config_entry.entry_id)