async def set_lcd_text( self, text_type: Optional[DoorbellMessageType], text: Optional[str] = None, reset_at: Union[None, datetime, DEFAULT_TYPE] = None, ) -> None: """Sets doorbell LCD text. Requires camera to be doorbell""" if not self.feature_flags.has_lcd_screen: raise BadRequest("Camera does not have an LCD screen") if text_type is None: self.lcd_message = None # UniFi Protect bug: clearing LCD text message does _not_ emit a WS message await self.save_device(force_emit=True) return if text_type != DoorbellMessageType.CUSTOM_MESSAGE: if text is not None: raise BadRequest( "Can only set text if text_type is CUSTOM_MESSAGE") text = text_type.value.replace("_", " ") if reset_at == DEFAULT: reset_at = utc_now( ) + self.api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout self.lcd_message = LCDMessage(api=self._api, type=text_type, text=text, reset_at=reset_at) await self.save_device()
async def add_custom_doorbell_message(self, message: str) -> None: """Adds custom doorbell message""" if len(message) > 30: raise BadRequest("Message length over 30 characters") if message in self.doorbell_settings.custom_messages: raise BadRequest("Custom doorbell message already exists") self.doorbell_settings.custom_messages.append(DoorbellText(message)) await self.save_device() self.update_all_messages()
def __init__(self, camera: Camera, content_url: str, ffmpeg_path: Optional[Path] = None): if not camera.feature_flags.has_speaker: raise BadRequest("Camera does not have a speaker for talkback") input_args = self.get_args_from_url(content_url) if len(input_args) > 0: input_args += " " # from Android app bitrate = 48000 # 8000 seems to result in best quality without overloading the camera udp_bitrate = bitrate + 8000 # vn = no video # acodec = audio codec to encode output in (aac) # ac = number of output channels (1) # ar = output sampling rate (22050) # b:a = set bit rate of output audio cmd = ( "-loglevel info -hide_banner " f"{input_args}-i {content_url} -vn " f"-acodec {camera.talkback_settings.type_fmt} -ac {camera.talkback_settings.channels} " f"-ar {camera.talkback_settings.sampling_rate} -b:a {bitrate} -map 0:a " f'-f adts "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={udp_bitrate}"' ) super().__init__(cmd, ffmpeg_path)
async def save_device(self, force_emit: bool = False) -> None: """ Generates a diff for unsaved changed on the device and sends them back to UFP USE WITH CAUTION, updates _all_ fields for the current object that have been changed. May have unexpected side effects. Tested updates have been added a methods on applicable devices. Args: * `force_emit`: Emit a fake UFP WS message. Should only be use for when UFP does not properly emit a WS message """ if self.model is None: raise BadRequest("Unknown model type") new_data = self.dict(exclude=self._get_excluded_changed_fields()) updated = self.unifi_dict(data=self.get_changed()) # do not patch when there are no updates if updated == {}: return await self._api_update(updated) self._initial_data = new_data if not force_emit: return await self.emit_message(updated)
def create_talkback_stream( self, content_url: str, ffmpeg_path: Optional[Path] = None) -> TalkbackStream: """ Creates a subprocess to play audio to a camera through its speaker. Requires ffmpeg to use. Args: * `content_url`: Either a URL accessible by python or a path to a file (ffmepg's `-i` parameter) * `ffmpeg_path`: Optional path to ffmpeg binary Use either `await stream.run_until_complete()` or `await stream.start()` to start subprocess command after getting the stream. `.play_audio()` is a helper that wraps this method and automatically runs the subprocess as well """ if self.talkback_stream is not None and self.talkback_stream.is_running: raise BadRequest("Camera is already playing audio") self.talkback_stream = TalkbackStream(self, content_url, ffmpeg_path) return self.talkback_stream
async def set_light_settings( self, mode: LightModeType, enable_at: Optional[LightModeEnableType] = None, duration: Optional[timedelta] = None, sensitivity: Optional[PercentInt] = None, ) -> None: """Updates various Light settings. Args: * `mode`: Light trigger mode * `enable_at`: Then the light automatically turns on by itself * `duration`: How long the light should remain on after motion, must be timedelta between 15s and 900s * `sensitivity`: PIR Motion sensitivity """ self.light_mode_settings.mode = mode if enable_at is not None: self.light_mode_settings.enable_at = enable_at if duration is not None: if duration.total_seconds() < 15 or duration.total_seconds() > 900: raise BadRequest("Duration outside of 15s to 900s range") self.light_device_settings.pir_duration = duration if sensitivity is not None: self.light_device_settings.pir_sensitivity = sensitivity await self.save_device()
async def get_video(self, channel_index: int = 0) -> Optional[bytes]: """Get the MP4 video clip for this given event Args: * `channel_index`: index of `CameraChannel` on the camera to use to retrieve video from Will raise an exception if event does not have a camera, end time or the channel index is wrong. """ if self.camera is None: raise BadRequest("Event does not have a camera") if self.end is None: raise BadRequest("Event is ongoing") return await self.api.get_camera_video(self.camera.id, self.start, self.end, channel_index)
async def emit_message(self, updated: Dict[str, Any]) -> None: """Emites fake WS message for ProtectApiClient to process.""" if updated == {}: _LOGGER.debug("Event ping callback started for %s", self.id) if self.model is None: raise BadRequest("Unknown model type") header = WSPacketFrameHeader( packet_type=1, payload_format=ProtectWSPayloadFormat.JSON.value, deflated=0, unknown=1, payload_size=1) action_frame = WSJSONPacketFrame() action_frame.header = header action_frame.data = { "action": "update", "newUpdateId": None, "modelKey": self.model.value, "id": self.id } data_frame = WSJSONPacketFrame() data_frame.header = header data_frame.data = updated message = self.api.bootstrap.process_ws_packet( WSPacket(action_frame.packed + data_frame.packed)) if message is not None: self.api.emit_message(message)
async def set_speaker_volume(self, level: PercentInt) -> None: """Sets the speaker sensitivity level on camera""" if not self.feature_flags.has_speaker: raise BadRequest("Camera does not have speaker") self.speaker_settings.volume = level await self.save_device()
async def set_mic_volume(self, level: PercentInt) -> None: """Sets the mic sensitivity level on camera""" if not self.feature_flags.has_mic: raise BadRequest("Camera does not have mic") self.mic_volume = level await self.save_device()
async def set_wdr_level(self, level: WDRLevel) -> None: """Sets WDR (Wide Dynamic Range) on camera""" if self.feature_flags.has_hdr: raise BadRequest("Cannot set WDR on cameras with HDR") self.isp_settings.wdr = level await self.save_device()
async def set_video_mode(self, mode: VideoMode) -> None: """Sets video mode on camera""" if mode not in self.feature_flags.video_modes: raise BadRequest(f"Camera does not have {mode}") self.video_mode = mode await self.save_device()
async def set_hdr(self, enabled: bool) -> None: """Sets HDR (High Dynamic Range) on camera""" if not self.feature_flags.has_hdr: raise BadRequest("Camera does not have HDR") self.hdr_mode = enabled await self.save_device()
async def set_ir_led_model(self, mode: IRLEDMode) -> None: """Sets IR LED mode on camera""" if not self.feature_flags.has_led_ir: raise BadRequest("Camera does not have an LED IR") self.isp_settings.ir_led_mode = mode await self.save_device()
async def set_duration(self, duration: timedelta) -> None: """Sets motion sensitivity""" if duration.total_seconds() < 15 or duration.total_seconds() > 900: raise BadRequest("Duration outside of 15s to 900s range") self.light_device_settings.pir_duration = duration await self.save_device()
async def set_camera_zoom(self, level: PercentInt) -> None: """Sets zoom level for camera""" if not self.feature_flags.can_optical_zoom: raise BadRequest("Camera cannot optical zoom") self.isp_settings.zoom_position = level await self.save_device()
async def set_chime_duration(self, duration: ChimeDuration) -> None: """Sets chime duration for doorbell. Requires camera to be a doorbell""" if not self.feature_flags.has_chime: raise BadRequest("Camera does not have a chime") self.chime_duration = duration await self.save_device()
async def set_status_light(self, enabled: bool) -> None: """Sets status indicicator light on camera""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") self.led_settings.is_enabled = enabled self.led_settings.blink_rate = 0 await self.save_device()
async def remove_custom_doorbell_message(self, message: str) -> None: """Removes custom doorbell message""" if message not in self.doorbell_settings.custom_messages: raise BadRequest("Custom doorbell message does not exists") self.doorbell_settings.custom_messages.remove(DoorbellText(message)) await self.save_device() self.update_all_messages()
def api(self) -> ProtectApiClient: """ ProtectApiClient that the UFP object was created with. If no API Client was passed in time of creation, will raise `BadRequest` """ if self._api is None: raise BadRequest("API Client not initialized") return self._api
async def save_device(self, force_emit: bool = False) -> None: """ Generates a diff for unsaved changed on the device and sends them back to UFP USE WITH CAUTION, updates _all_ fields for the current object that have been changed. May have unexpected side effects. Tested updates have been added a methods on applicable devices. Args: * `force_emit`: Emit a fake UFP WS message. Should only be use for when UFP does not properly emit a WS message """ if self.model is None: raise BadRequest("Unknown model type") new_data = self.dict(exclude=self._get_excluded_changed_fields()) updated = self.unifi_dict(data=self.get_changed()) # do not patch when there are no updates if updated == {}: return await self._api_update(updated) self._initial_data = new_data if not force_emit: return header = WSPacketFrameHeader( packet_type=1, payload_format=ProtectWSPayloadFormat.JSON.value, deflated=0, unknown=1, payload_size=1) action_frame = WSJSONPacketFrame() action_frame.header = header action_frame.data = { "action": "update", "newUpdateId": None, "modelKey": self.model.value, "id": self.id } data_frame = WSJSONPacketFrame() data_frame.header = header data_frame.data = updated message = self.api.bootstrap.process_ws_packet( WSPacket(action_frame.packed + data_frame.packed)) if message is not None: self.api.emit_message(message)
def __init__(self, camera: Camera, content_url: str, ffmpeg_path: Optional[Path] = None): if not camera.feature_flags.has_speaker: raise BadRequest("Camera does not have a speaker for talkback") input_args = self.get_args_from_url(content_url) if len(input_args) > 0: input_args += " " cmd = f"-loglevel info -hide_banner {input_args}-i {content_url} -vn -acodec {camera.talkback_settings.type_fmt} -ac {camera.talkback_settings.channels} -ar {camera.talkback_settings.sampling_rate} -bits_per_raw_sample {camera.talkback_settings.bits_per_sample} -map 0:a -aq {camera.talkback_settings.quality} -f adts udp://{camera.host}:{camera.talkback_settings.bind_port}" super().__init__(cmd, ffmpeg_path)
async def get_smart_detect_track(self) -> SmartDetectTrack: """ Gets smart detect track for given smart detect event. If event is not a smart detect event, it will raise a `BadRequest` """ if self.type != EventType.SMART_DETECT: raise BadRequest("Not a smart detect event") if self._smart_detect_track is None: self._smart_detect_track = await self.api.get_event_smart_detect_track(self.id) return self._smart_detect_track
async def set_liveview(self, liveview: Liveview) -> None: """ Sets the liveview current set for the viewer Args: * `liveview`: The liveview you want to set """ if self._api is not None: if liveview.id not in self._api.bootstrap.liveviews: raise BadRequest("Unknown liveview") self.liveview_id = liveview.id # UniFi Protect bug: changing the liveview does _not_ emit a WS message await self.save_device(force_emit=True)
async def get_smart_detect_zones(self) -> Dict[int, CameraZone]: """Gets the triggering zones for the smart detection""" if self.camera is None: raise BadRequest("No camera on event") if self._smart_detect_zones is None: smart_track = await self.get_smart_detect_track() ids: Set[int] = set() for item in smart_track.payload: ids = ids | set(item.zone_ids) self._smart_detect_zones = {z.id: z for z in self.camera.smart_detect_zones if z.id in ids} return self._smart_detect_zones
async def set_privacy( self, enabled: bool, mic_level: Optional[PercentInt] = None, recording_mode: Optional[RecordingMode] = None) -> None: """Adds/removes a privacy zone that blacks out the whole camera""" if not self.feature_flags.has_privacy_mask: raise BadRequest("Camera does not allow privacy zones") if enabled: self.add_privacy_zone() else: self.remove_privacy_zone() if mic_level is not None: self.mic_volume = mic_level if recording_mode is not None: self.recording_settings.mode = recording_mode await self.save_device()
def bootstrap(self) -> Bootstrap: if self._bootstrap is None: raise BadRequest("Client not initalized, run `update` first") return self._bootstrap