"""Defines Blink cameras.""" import copy import string import os import logging import datetime from json import dumps import traceback import aiohttp from aiofiles import open from requests.compat import urljoin from blinkpy import api from blinkpy.helpers.constants import TIMEOUT_MEDIA from blinkpy.helpers.util import to_alphanumeric from blinkpy.livestream import BlinkLiveStream _LOGGER = logging.getLogger(__name__) class BlinkCamera: """Class to initialize individual camera.""" def __init__(self, sync): """Initialize BlinkCamera.""" self.sync = sync self.name = None self.camera_id = None self.network_id = None self.thumbnail = None self.serial = None self._version = None self.motion_enabled = None self.battery_level = None self._battery_voltage = None self.clip = None # A clip remains in the recent clips list until is has # been downloaded or has been expired. self.recent_clips = [] self.temperature = None self.temperature_calibrated = None self.battery_state = None self.motion_detected = None self.wifi_strength = None self.last_record = None self._cached_image = None self._cached_video = None self.camera_type = "" self.product_type = None self.sync_signal_strength = None @property def attributes(self): """Return dictionary of all camera attributes.""" attributes = { "name": self.name, "camera_id": self.camera_id, "serial": self.serial, "version": self._version, "temperature": self.temperature, "temperature_c": self.temperature_c, "temperature_calibrated": self.temperature_calibrated, "battery": self.battery, "battery_level": self.battery_level, "battery_voltage": self._battery_voltage, "thumbnail": self.thumbnail, "video": self.clip, "recent_clips": self.recent_clips, "motion_enabled": self.motion_enabled, "motion_detected": self.motion_detected, "wifi_strength": self.wifi_strength, "network_id": self.sync.network_id, "sync_module": self.sync.name, "sync_signal_strength": self.sync_signal_strength, "last_record": self.last_record, "type": self.product_type, } return attributes @property def battery(self): """Return battery as string.""" return self.battery_state @property def battery_voltage(self): """Return battery voltage as a number in 100ths of volts, so 165 = 1.65v.""" return self._battery_voltage @property def temperature_c(self): """Return temperature in celsius.""" try: return round((self.temperature - 32) / 9.0 * 5.0, 1) except TypeError: return None @property def image_from_cache(self): """Return the most recently cached image.""" if self._cached_image: return self._cached_image return None @property def video_from_cache(self): """Return the most recently cached video.""" if self._cached_video: return self._cached_video return None @property def version(self): """Return the camera Firmware version.""" return self._version @property def arm(self): """Return arm status of camera.""" return self.motion_enabled async def async_arm(self, value): """Set camera arm status.""" if value: return await api.request_motion_detection_enable( self.sync.blink, self.network_id, self.camera_id, camera_type=self.camera_type, ) return await api.request_motion_detection_disable( self.sync.blink, self.network_id, self.camera_id, camera_type=self.camera_type, ) @property async def night_vision(self): """Return night_vision status.""" res = await api.request_get_config( self.sync.blink, self.network_id, self.camera_id, product_type=self.product_type, ) if res is None: return None if self.product_type == "catalina": res = res.get("camera", [{}])[0] if res["illuminator_enable"] in [0, 1, 2]: res["illuminator_enable"] = ["off", "on", "auto"][ res.get("illuminator_enable") ] nv_keys = [ "night_vision_control", "illuminator_enable", "illuminator_enable_v2", ] return {key: res.get(key) for key in nv_keys} async def async_set_night_vision(self, value): """Set camera night_vision status.""" if value not in ["on", "off", "auto"]: return None if self.product_type == "catalina": value = {"off": 0, "on": 1, "auto": 2}.get(value, None) data = dumps({"illuminator_enable": value}) res = await api.request_update_config( self.sync.blink, self.network_id, self.camera_id, product_type=self.product_type, data=data, ) if res and res.status == 200: return await res.json() return None async def record(self): """Initiate clip recording.""" return await api.request_new_video( self.sync.blink, self.network_id, self.camera_id, camera_type=self.camera_type, ) async def get_media(self, media_type="image") -> aiohttp.ClientRequest: """Download media (image or video).""" if media_type.lower() == "video": return await self.get_video_clip() return await self.get_thumbnail() async def get_thumbnail(self, url=None): """Download thumbnail image.""" if not url: url = self.thumbnail if not url: _LOGGER.warning("Thumbnail URL not available: self.thumbnail=%s", url) return None return await api.http_get( self.sync.blink, url=url, stream=True, json=False, timeout=TIMEOUT_MEDIA, ) async def get_video_clip(self, url=None): """Download video clip.""" if not url: url = self.clip if not url: _LOGGER.warning("Video clip URL not available: self.clip=%s", url) return None return await api.http_get( self.sync.blink, url=url, stream=True, json=False, timeout=TIMEOUT_MEDIA, ) async def snap_picture(self): """Take a picture with camera to create a new thumbnail.""" ret_val = await api.request_new_image( self.sync.blink, self.network_id, self.camera_id, camera_type=self.camera_type, ) response = await self.get_media() if response and response.status == 200: self._cached_image = await response.read() return ret_val async def set_motion_detect(self, enable): """Set motion detection.""" _LOGGER.warning( "Method is deprecated as of v0.16.0 and will be removed in " "a future version. Please use the BlinkCamera.arm property instead." ) return await self.async_arm(enable) async def update(self, config, force_cache=False, expire_clips=True, **kwargs): """Update camera info.""" if bool(config): self.extract_config_info(config) await self.get_sensor_info() await self.update_images( config, force_cache=force_cache, expire_clips=expire_clips ) def extract_config_info(self, config): """Extract info from config.""" self.name = config.get("name", "unknown") self.camera_id = str(config.get("id", "unknown")) self.network_id = str(config.get("network_id", "unknown")) self.serial = config.get("serial") self._version = config.get("fw_version") self.motion_enabled = config.get("enabled", "unknown") self._battery_voltage = config.get("battery_voltage", None) self.battery_state = config.get("battery_state") or config.get("battery") self.wifi_strength = config.get("wifi_strength") if signals := config.get("signals"): self.battery_level = signals.get("battery") self.sync_signal_strength = signals.get("lfr") self.temperature = signals.get("temp") else: self.temperature = config.get("temperature") self.product_type = config.get("type") async def get_sensor_info(self): """Retrieve calibrated temperature from special endpoint.""" resp = await api.request_camera_sensors( self.sync.blink, self.network_id, self.camera_id ) try: self.temperature_calibrated = resp["temp"] except (TypeError, KeyError): self.temperature_calibrated = self.temperature _LOGGER.warning( "Could not retrieve calibrated temperature response %s.", resp ) _LOGGER.warning( "for network_id (%s) and camera_id (%s)", self.network_id, self.camera_id, ) async def update_images(self, config, force_cache=False, expire_clips=True): """Update images for camera.""" new_thumbnail = None thumb_addr = None thumb_string = None if config.get("thumbnail", False): thumb_addr = config["thumbnail"] try: # API update only returns the timestamp! int(thumb_addr) thumb_string = ( "/api/v3/media/accounts/" f"{self.sync.blink.account_id}/networks/" f"{self.network_id}/{self.product_type}/" f"{self.camera_id}/thumbnail/" f"thumbnail.jpg?ts={thumb_addr}&ext=" ) except ValueError: # This is the old API and has the full url thumb_string = f"{thumb_addr}.jpg" # Check that new full api url has not been returned: if thumb_addr.endswith("&ext="): thumb_string = thumb_addr if thumb_string is not None: new_thumbnail = urljoin(self.sync.urls.base_url, thumb_string) else: _LOGGER.warning("Could not find thumbnail for camera %s.", self.name) try: self.motion_detected = self.sync.motion[self.name] except KeyError: self.motion_detected = False clip_addr = None try: def timesort(record): rec_time = record["time"] iso_time = datetime.datetime.fromisoformat(rec_time) stamp = int(iso_time.timestamp()) return stamp if ( len(self.sync.last_records) > 0 and len(self.sync.last_records[self.name]) > 0 ): last_records = sorted(self.sync.last_records[self.name], key=timesort) for rec in last_records: clip_addr = rec["clip"] self.clip = f"{self.sync.urls.base_url}{clip_addr}" self.last_record = rec["time"] if self.motion_detected: recent = {"time": self.last_record, "clip": self.clip} # Prevent duplicates. if recent not in self.recent_clips: self.recent_clips.append(recent) if len(self.recent_clips) > 0: _LOGGER.debug( "Found %s recent clips for %s", len(self.recent_clips), self.name, ) _LOGGER.debug( "Most recent clip for %s was created at %s : %s", self.name, self.last_record, self.clip, ) except (KeyError, IndexError): ex = traceback.format_exc() trace = "".join(traceback.format_stack()) _LOGGER.error("Error getting last records for '%s': %s", self.name, ex) _LOGGER.debug("\n%s", trace) # If the thumbnail or clip have changed, update the cache update_cached_image = False if new_thumbnail != self.thumbnail or self._cached_image is None: update_cached_image = True self.thumbnail = new_thumbnail update_cached_video = False if self._cached_video is None or self.motion_detected: update_cached_video = True if new_thumbnail is not None and (update_cached_image or force_cache): response = await self.get_media() if response and response.status == 200: self._cached_image = await response.read() if clip_addr is not None and (update_cached_video or force_cache): response = await self.get_media(media_type="video") if response and response.status == 200: self._cached_video = await response.read() # Don't let the recent clips list grow without bound. if expire_clips: await self.expire_recent_clips() async def expire_recent_clips(self, delta=datetime.timedelta(hours=1)): """Remove recent clips from list when they get too old.""" to_keep = [] for clip in self.recent_clips: timedelta = (datetime.datetime.now() - delta).timestamp() clip_time = datetime.datetime.fromisoformat(clip["time"]).timestamp() if clip_time > timedelta: to_keep.append(clip) num_expired = len(self.recent_clips) - len(to_keep) if num_expired > 0: _LOGGER.info("Expired %s clips from '%s'", num_expired, self.name) self.recent_clips = copy.deepcopy(to_keep) if len(self.recent_clips) > 0: _LOGGER.info( "'%s' has %s clips available for download", self.name, len(self.recent_clips), ) for clip in self.recent_clips: url = clip["clip"] if "local_storage" in url: await api.http_post(self.sync.blink, url) async def get_liveview(self): """Get liveview rtsps link.""" response = await api.request_camera_liveview( self.sync.blink, self.sync.network_id, self.camera_id, camera_type=self.camera_type, ) return response["server"] async def init_livestream(self): """Initialize livestream.""" response = await api.request_camera_liveview( self.sync.blink, self.sync.network_id, self.camera_id, camera_type=self.camera_type, ) if not response["server"].startswith("immis://"): raise NotImplementedError("Unsupported: {}".format(response["server"])) return BlinkLiveStream(self, response) async def image_to_file(self, path): """ Write image to file. :param path: Path to write file """ _LOGGER.debug("Writing image from %s to %s", self.name, path) response = await self.get_media() if response and response.status == 200: async with open(path, "wb") as imagefile: await imagefile.write(await response.read()) else: _LOGGER.error("Cannot write image to file, response %s", response.status) async def video_to_file(self, path): """ Write video to file. :param path: Path to write file """ _LOGGER.debug("Writing video from %s to %s", self.name, path) response = await self.get_media(media_type="video") if response is None: _LOGGER.error("No saved video exists for %s.", self.name) return async with open(path, "wb") as vidfile: await vidfile.write(await response.read()) async def save_recent_clips( self, output_dir="/tmp", file_pattern="${created}_${name}.mp4" ): """Save all recent clips using timestamp file name pattern.""" if output_dir[-1] != "/": output_dir += "/" recent = copy.deepcopy(self.recent_clips) num_saved = 0 for clip in recent: clip_time = datetime.datetime.fromisoformat(clip["time"]) clip_time_local = clip_time.replace( tzinfo=datetime.timezone.utc ).astimezone(tz=None) created_at = clip_time_local.strftime("%Y%m%d_%H%M%S") clip_addr = clip["clip"] file_name = string.Template(file_pattern).substitute( created=created_at, name=to_alphanumeric(self.name) ) path = os.path.join(output_dir, file_name) _LOGGER.debug("Saving %s to %s", clip_addr, path) media = await self.get_video_clip(clip_addr) if media and media.status == 200: async with open(path, "wb") as clip_file: await clip_file.write(await media.read()) num_saved += 1 try: # Remove recent clip from the list once the download has finished. self.recent_clips.remove(clip) _LOGGER.debug("Removed %s from recent clips", clip) except ValueError: ex = traceback.format_exc() _LOGGER.error("Error removing clip from list: %s", ex) trace = "".join(traceback.format_stack()) _LOGGER.debug("\n%s", trace) if len(recent) == 0: _LOGGER.info("No recent clips to save for '%s'.", self.name) else: _LOGGER.info( "Saved %s of %s recent clips from '%s' to directory %s", num_saved, len(recent), self.name, output_dir, ) class BlinkCameraMini(BlinkCamera): """Define a class for a Blink Mini camera.""" def __init__(self, sync): """Initialize a Blink Mini cameras.""" super().__init__(sync) self.camera_type = "mini" @property def arm(self): """Return camera arm status.""" return self.sync.arm async def get_sensor_info(self): """Get sensor info for blink mini camera.""" class BlinkDoorbell(BlinkCamera): """Define a class for a Blink Doorbell camera.""" def __init__(self, sync): """Initialize a Blink Doorbell.""" super().__init__(sync) self.camera_type = "doorbell" async def get_sensor_info(self): """Get sensor info for blink doorbell camera."""