async def test_ws_emit_alarm_callback( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, sensor, packet: WSPacket ): mock_now.return_value = now protect_client = protect_client_no_debug protect_client.emit_message = Mock() # type: ignore obj = protect_client.bootstrap.sensors[sensor["id"]] expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore action_frame.data = { "action": "update", "newUpdateId": expected_updated_id, "modelKey": "sensor", "id": sensor["id"], } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore data_frame.data = {"alarmTriggeredAt": to_js_time(now)} msg = MagicMock() msg.data = packet.pack_frames() assert not obj.is_alarm_detected with patch("pyunifiprotect.data.bootstrap.utc_now", mock_now): protect_client._process_ws_message(msg) assert obj.is_alarm_detected mock_now.return_value = utc_now() + EVENT_PING_INTERVAL assert not obj.is_alarm_detected protect_client.emit_message.assert_called_once()
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()
def _process_device_update( self, packet: WSPacket, action: Dict[str, Any], data: Dict[str, Any], ignore_stats: bool) -> Optional[WSSubscriptionMessage]: model_type = action["modelKey"] data = _remove_stats_keys(data, ignore_stats) # nothing left to process if len(data) == 0: self._create_stat(packet, [], True) return None key = model_type + "s" devices = getattr(self, key) if action["id"] in devices: obj: ProtectModelWithId = devices[action["id"]] data = obj.unifi_dict_to_dict(data) old_obj = obj.copy() obj = obj.update_from_dict(deepcopy(data)) now = utc_now() if isinstance(obj, Event): self.process_event(obj) elif isinstance(obj, Camera): if "last_ring" in data and obj.last_ring: is_recent = obj.last_ring + RECENT_EVENT_MAX >= now _LOGGER.debug("last_ring for %s (%s)", obj.id, is_recent) if is_recent: obj.set_ring_timeout() elif isinstance(obj, Sensor): if "alarm_triggered_at" in data and obj.alarm_triggered_at: is_recent = obj.alarm_triggered_at + RECENT_EVENT_MAX >= now _LOGGER.debug("alarm_triggered_at for %s (%s)", obj.id, is_recent) if is_recent: obj.set_alarm_timeout() elif "tampering_detected_at" in data and obj.tampering_detected_at: is_recent = obj.tampering_detected_at + RECENT_EVENT_MAX >= now _LOGGER.debug("tampering_detected_at for %s (%s)", obj.id, is_recent) if is_recent: obj.set_tampering_timeout() devices[action["id"]] = obj self._create_stat(packet, list(data.keys()), False) return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=data, new_obj=obj, old_obj=old_obj, ) # ignore updates to events that phase out if model_type != ModelType.EVENT.value: _LOGGER.debug("Unexpected %s: %s", key, action["id"]) return None
def update_from_dict(self, data: Dict[str, Any]) -> Camera: # a message in the past is actually a singal to wipe the message reset_at = data.get("lcd_message", {}).get("reset_at") if reset_at is not None: reset_at = from_js_time(reset_at) if utc_now() > reset_at: data["lcd_message"] = None return super().update_from_dict(data)
async def async_set_doorbell_lcd_message(self, message: str, duration: str) -> None: """Set LCD Message on Doorbell display.""" reset_at = None if duration.isnumeric(): reset_at = utc_now() + timedelta(minutes=int(duration)) await self.device.set_lcd_text( DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at )
async def async_set_doorbell_message(self, message: str, duration: str) -> None: """Set LCD Message on Doorbell display.""" if self.entity_description.key != _KEY_DOORBELL_TEXT: raise HomeAssistantError("Not a doorbell text select entity") assert isinstance(self.device, Camera) reset_at = None if duration.isnumeric(): reset_at = utc_now() + timedelta(minutes=int(duration)) await self.device.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at)
async def get_events_raw( self, start: Optional[datetime] = None, end: Optional[datetime] = None, limit: Optional[int] = None, camera_ids: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """ Get list of events from Protect Args: * `start`: start time for events * `end`: end time for events * `limit`: max number of events to return * `camera_ids`: list of Cameras to get events for If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours. If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or `limit` must be provided. Otherwise, you will get a 400 error from Unifi Protect Providing a list of Camera IDs will not prevent non-camera events from returning. """ # if no parameters are passed in, default to all events from last 24 hours if limit is None and start is None and end is None: end = utc_now() + timedelta(seconds=10) start = end - timedelta(hours=24) params: Dict[str, Any] = {} if limit is not None: params["limit"] = limit if start is not None: params["start"] = to_js_time(start) if end is not None: params["end"] = to_js_time(end) if camera_ids is not None: params["cameras"] = ",".join(camera_ids) return await self.api_request_list("events", params=params)
async def update(self, force: bool = False) -> Optional[Bootstrap]: """ Updates the state of devices, initalizes `.bootstrap` and connects to UFP Websocket for real time updates You can use the various other `get_` methods if you need one off data from UFP """ now = time.monotonic() now_dt = utc_now() max_event_dt = now_dt - timedelta(hours=24) if force: self._last_update = NEVER_RAN self._last_update_dt = max_event_dt bootstrap_updated = False if self._bootstrap is None or now - self._last_update > DEVICE_UPDATE_INTERVAL: bootstrap_updated = True self._last_update = now self._last_update_dt = now_dt self._bootstrap = await self.get_bootstrap() await self.async_connect_ws(force) active_ws = self.check_ws() if not bootstrap_updated and active_ws: # If the websocket is connected/connecting # we do not need to get events _LOGGER.debug("Skipping update since websocket is active") return None events = await self.get_events(start=self._last_update_dt or max_event_dt, end=now_dt) for event in events: self.bootstrap.process_event(event) self._last_update = now self._last_update_dt = now_dt return self._bootstrap
def _async_update_device_from_protect(self) -> None: super()._async_update_device_from_protect() if self.entity_description.ufp_value is None: return if self.entity_description.key == _KEY_DOORBELL: last_ring = get_nested_attr(self.device, self.entity_description.ufp_value) now = utc_now() is_ringing = (False if last_ring is None else (now - last_ring) < RING_INTERVAL) if is_ringing: if self._doorbell_callback is not None: self._doorbell_callback.cancel() self._doorbell_callback = asyncio.ensure_future( self._async_wait_for_doorbell(last_ring + RING_INTERVAL)) self._attr_is_on = is_ringing else: self._attr_is_on = get_nested_attr( self.device, self.entity_description.ufp_value)
async def get_camera_snapshot( self, camera_id: str, width: Optional[int] = None, height: Optional[int] = None, ) -> Optional[bytes]: """Gets a snapshot from a camera""" dt = utc_now() # ts is only used as a cache buster params = { "ts": to_js_time(dt), "force": "true", } if width is not None: params.update({"w": width}) if height is not None: params.update({"h": height}) return await self.api_request_raw(f"cameras/{camera_id}/snapshot", params=params, raise_exception=False)
def get_changed(self) -> Dict[str, Any]: updated = super().get_changed() if "lcd_message" in updated: lcd_message = updated["lcd_message"] # to "clear" LCD message, set reset_at to a time in the past if lcd_message is None: updated["lcd_message"] = { "reset_at": utc_now() - timedelta(seconds=10) } # otherwise, pass full LCD message to prevent issues elif self.lcd_message is not None: updated["lcd_message"] = self.lcd_message.dict() # if reset_at is not passed in, it will default to reset in 1 minute if lcd_message is not None and "reset_at" not in lcd_message: if self.lcd_message is None: updated["lcd_message"]["reset_at"] = None else: updated["lcd_message"][ "reset_at"] = self.lcd_message.reset_at return updated
async def _async_wait_for_doorbell(self, end_time: datetime) -> None: _LOGGER.debug("Doorbell callback started") while utc_now() < end_time: await asyncio.sleep(1) _LOGGER.debug("Doorbell callback ended") self._async_updated_event()