Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Support for Ubiquiti's UniFi Protect NVR."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Generator
6 import logging
7 
8 from uiprotect.data import (
9  Camera as UFPCamera,
10  CameraChannel,
11  ProtectAdoptableDeviceModel,
12  StateType,
13 )
14 
15 from homeassistant.components.camera import Camera, CameraEntityFeature
16 from homeassistant.core import HomeAssistant, callback
17 from homeassistant.helpers import issue_registry as ir
18 from homeassistant.helpers.dispatcher import async_dispatcher_connect
19 from homeassistant.helpers.entity_platform import AddEntitiesCallback
20 from homeassistant.helpers.issue_registry import IssueSeverity
21 
22 from .const import (
23  ATTR_BITRATE,
24  ATTR_CHANNEL_ID,
25  ATTR_FPS,
26  ATTR_HEIGHT,
27  ATTR_WIDTH,
28  DOMAIN,
29 )
30 from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
31 from .entity import ProtectDeviceEntity
32 from .utils import get_camera_base_name
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 
37 @callback
39  hass: HomeAssistant, entry: UFPConfigEntry, data: ProtectData, camera: UFPCamera
40 ) -> None:
41  edit_key = "readonly"
42  if camera.can_write(data.api.bootstrap.auth_user):
43  edit_key = "writable"
44 
45  translation_key = f"rtsp_disabled_{edit_key}"
46  issue_key = f"rtsp_disabled_{camera.id}"
47 
48  ir.async_create_issue(
49  hass,
50  DOMAIN,
51  issue_key,
52  is_fixable=True,
53  is_persistent=False,
54  learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#camera-streams",
55  severity=IssueSeverity.WARNING,
56  translation_key=translation_key,
57  translation_placeholders={"camera": camera.display_name},
58  data={"entry_id": entry.entry_id, "camera_id": camera.id},
59  )
60 
61 
62 @callback
64  hass: HomeAssistant,
65  entry: UFPConfigEntry,
66  data: ProtectData,
67  ufp_device: UFPCamera | None = None,
68 ) -> Generator[tuple[UFPCamera, CameraChannel, bool]]:
69  """Get all the camera channels."""
70 
71  cameras = data.get_cameras() if ufp_device is None else [ufp_device]
72  for camera in cameras:
73  if not camera.channels:
74  if ufp_device is None:
75  # only warn on startup
76  _LOGGER.warning(
77  "Camera does not have any channels: %s (id: %s)",
78  camera.display_name,
79  camera.id,
80  )
81  data.async_add_pending_camera_id(camera.id)
82  continue
83 
84  is_default = True
85  for channel in camera.channels:
86  if channel.is_package:
87  yield camera, channel, True
88  elif channel.is_rtsp_enabled:
89  yield camera, channel, is_default
90  is_default = False
91 
92  # no RTSP enabled use first channel with no stream
93  if is_default and not camera.is_third_party_camera:
94  _create_rtsp_repair(hass, entry, data, camera)
95  yield camera, camera.channels[0], True
96  else:
97  ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{camera.id}")
98 
99 
101  hass: HomeAssistant,
102  entry: UFPConfigEntry,
103  data: ProtectData,
104  ufp_device: UFPCamera | None = None,
105 ) -> list[ProtectDeviceEntity]:
106  disable_stream = data.disable_stream
107  entities: list[ProtectDeviceEntity] = []
108  for camera, channel, is_default in _get_camera_channels(
109  hass, entry, data, ufp_device
110  ):
111  # do not enable streaming for package camera
112  # 2 FPS causes a lot of buferring
113  entities.append(
115  data,
116  camera,
117  channel,
118  is_default,
119  True,
120  disable_stream or channel.is_package,
121  )
122  )
123 
124  if channel.is_rtsp_enabled and not channel.is_package:
125  entities.append(
127  data,
128  camera,
129  channel,
130  is_default,
131  False,
132  disable_stream,
133  )
134  )
135  return entities
136 
137 
139  hass: HomeAssistant,
140  entry: UFPConfigEntry,
141  async_add_entities: AddEntitiesCallback,
142 ) -> None:
143  """Discover cameras on a UniFi Protect NVR."""
144  data = entry.runtime_data
145 
146  @callback
147  def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
148  if not isinstance(device, UFPCamera):
149  return
150  async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device))
151 
152  data.async_subscribe_adopt(_add_new_device)
153  entry.async_on_unload(
154  async_dispatcher_connect(hass, data.channels_signal, _add_new_device)
155  )
156  async_add_entities(_async_camera_entities(hass, entry, data))
157 
158 
159 _DISABLE_FEATURE = CameraEntityFeature(0)
160 _ENABLE_FEATURE = CameraEntityFeature.STREAM
161 
162 
164  """A Ubiquiti UniFi Protect Camera."""
165 
166  device: UFPCamera
167  _state_attrs = (
168  "_attr_available",
169  "_attr_is_recording",
170  "_attr_motion_detection_enabled",
171  )
172 
173  def __init__(
174  self,
175  data: ProtectData,
176  camera: UFPCamera,
177  channel: CameraChannel,
178  is_default: bool,
179  secure: bool,
180  disable_stream: bool,
181  ) -> None:
182  """Initialize an UniFi camera."""
183  self.channelchannel = channel
184  self._secure_secure = secure
185  self._disable_stream_disable_stream = disable_stream
186  self._last_image_last_image: bytes | None = None
187  super().__init__(data, camera)
188  device = self.devicedevice
189 
190  camera_name = get_camera_base_name(channel)
191  if self._secure_secure:
192  self._attr_unique_id_attr_unique_id_attr_unique_id = f"{device.mac}_{channel.id}"
193  self._attr_name_attr_name_attr_name = camera_name
194  else:
195  self._attr_unique_id_attr_unique_id_attr_unique_id = f"{device.mac}_{channel.id}_insecure"
196  self._attr_name_attr_name_attr_name = f"{camera_name} (insecure)"
197  # only the default (first) channel is enabled by default
198  self._attr_entity_registry_enabled_default_attr_entity_registry_enabled_default = is_default and secure
199  # Set the stream source before finishing the init
200  # because async_added_to_hass is too late and camera
201  # integration uses async_internal_added_to_hass to access
202  # the stream source which is called before async_added_to_hass
203  self._async_set_stream_source_async_set_stream_source()
204 
205  @callback
206  def _async_set_stream_source(self) -> None:
207  channel = self.channelchannel
208  enable_stream = not self._disable_stream_disable_stream and channel.is_rtsp_enabled
209  # SRTP disabled because go2rtc does not support it
210  # https://github.com/AlexxIT/go2rtc/#source-rtsp
211  rtsp_url = channel.rtsps_no_srtp_url if self._secure_secure else channel.rtsp_url
212  source = rtsp_url if enable_stream else None
213  self._attr_supported_features_attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE
214  self._stream_source_stream_source = source
215 
216  @callback
217  def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
218  super()._async_update_device_from_protect(device)
219  updated_device = self.devicedevice
220  channel = updated_device.channels[self.channelchannel.id]
221  self.channelchannel = channel
222  motion_enabled = updated_device.recording_settings.enable_motion_detection
223  self._attr_motion_detection_enabled_attr_motion_detection_enabled = (
224  motion_enabled if motion_enabled is not None else True
225  )
226  state_type_is_connected = updated_device.state is StateType.CONNECTED
227  self._attr_is_recording_attr_is_recording = (
228  state_type_is_connected and updated_device.is_recording
229  )
230  is_connected = self.datadata.last_update_success and state_type_is_connected
231  # some cameras have detachable lens that could cause the camera to be offline
232  self._attr_available_attr_available_attr_available = is_connected and updated_device.is_video_ready
233 
234  self._async_set_stream_source_async_set_stream_source()
235  self._attr_extra_state_attributes_attr_extra_state_attributes = {
236  ATTR_WIDTH: channel.width,
237  ATTR_HEIGHT: channel.height,
238  ATTR_FPS: channel.fps,
239  ATTR_BITRATE: channel.bitrate,
240  ATTR_CHANNEL_ID: channel.id,
241  }
242 
244  self, width: int | None = None, height: int | None = None
245  ) -> bytes | None:
246  """Return the Camera Image."""
247  if self.channelchannel.is_package:
248  last_image = await self.devicedevice.get_package_snapshot(width, height)
249  else:
250  last_image = await self.devicedevice.get_snapshot(width, height)
251  self._last_image_last_image = last_image
252  return self._last_image_last_image
253 
254  async def stream_source(self) -> str | None:
255  """Return the Stream Source."""
256  return self._stream_source_stream_source
257 
258  async def async_enable_motion_detection(self) -> None:
259  """Call the job and enable motion detection."""
260  await self.devicedevice.set_motion_detection(True)
261 
262  async def async_disable_motion_detection(self) -> None:
263  """Call the job and disable motion detection."""
264  await self.devicedevice.set_motion_detection(False)
None __init__(self, ProtectData data, UFPCamera camera, CameraChannel channel, bool is_default, bool secure, bool disable_stream)
Definition: camera.py:181
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:245
None _async_update_device_from_protect(self, ProtectDeviceType device)
Definition: camera.py:217
None _create_rtsp_repair(HomeAssistant hass, UFPConfigEntry entry, ProtectData data, UFPCamera camera)
Definition: camera.py:40
None async_setup_entry(HomeAssistant hass, UFPConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: camera.py:142
Generator[tuple[UFPCamera, CameraChannel, bool]] _get_camera_channels(HomeAssistant hass, UFPConfigEntry entry, ProtectData data, UFPCamera|None ufp_device=None)
Definition: camera.py:68
list[ProtectDeviceEntity] _async_camera_entities(HomeAssistant hass, UFPConfigEntry entry, ProtectData data, UFPCamera|None ufp_device=None)
Definition: camera.py:105
str get_camera_base_name(CameraChannel channel)
Definition: utils.py:130
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103