Buckets:
| """Support for Xiaomi cameras.""" | |
| import logging | |
| import json | |
| import time | |
| import locale | |
| import base64 | |
| import requests | |
| import re | |
| import collections | |
| from os import urandom | |
| from functools import partial | |
| from urllib.parse import urlencode | |
| from datetime import datetime, timedelta | |
| from homeassistant.const import STATE_IDLE | |
| from homeassistant.core import HomeAssistant | |
| from homeassistant.components.camera import ( | |
| DOMAIN as ENTITY_DOMAIN, | |
| Camera, | |
| CameraEntityFeature, # v2022.5 | |
| ) | |
| from homeassistant.components.ffmpeg import async_get_image, DATA_FFMPEG | |
| from homeassistant.helpers.event import async_track_point_in_utc_time | |
| from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream | |
| from haffmpeg.camera import CameraMjpeg | |
| from . import ( | |
| DOMAIN, | |
| CONF_MODEL, | |
| XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA, # noqa: F401 | |
| Device, | |
| HassEntry, | |
| XEntity, | |
| MiotToggleEntity, | |
| BaseSubEntity, | |
| MiotCloud, | |
| MiCloudException, | |
| async_setup_config_entry, | |
| bind_services_to_entries, | |
| ) | |
| from .core.const import CameraState | |
| from .core.miot_spec import ( | |
| MiotSpec, | |
| MiotService, | |
| ) | |
| _LOGGER = logging.getLogger(__name__) | |
| DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}' | |
| SCAN_INTERVAL = timedelta(seconds=60) | |
| SERVICE_TO_METHOD = {} | |
| async def async_setup_entry(hass, config_entry, async_add_entities): | |
| HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities) | |
| await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) | |
| async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): | |
| hass.data.setdefault(DATA_KEY, {}) | |
| hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities | |
| config['hass'] = hass | |
| model = str(config.get(CONF_MODEL) or '') | |
| spec = hass.data[DOMAIN]['miot_specs'].get(model) | |
| entities = [] | |
| if isinstance(spec, MiotSpec): | |
| svs = spec.get_services(ENTITY_DOMAIN, 'camera_control', 'video_doorbell') | |
| if svs: | |
| svs = [] # migrate to converter | |
| elif spec.services: | |
| srv = None | |
| if spec.name in ['video_doorbell']: | |
| # loock.cateye.v02 | |
| srv = spec.get_service('p2p_stream') or spec.first_service() | |
| elif model in [ | |
| 'lumi.lock.bmcn05', | |
| 'lumi.lock.mcn002', | |
| 'lumi.lock.wbmcn1', | |
| 'loock.lock.t1pro', | |
| ]: | |
| srv = spec.first_service() | |
| if isinstance(srv, MiotService): | |
| svs = [srv] | |
| for srv in svs: | |
| entities.append(MiotCameraEntity(hass, config, srv)) | |
| for entity in entities: | |
| hass.data[DOMAIN]['entities'][entity.unique_id] = entity | |
| async_add_entities(entities) | |
| bind_services_to_entries(hass, SERVICE_TO_METHOD) | |
| class BaseCameraEntity(Camera): | |
| _state_attrs: dict | |
| _last_image = None | |
| _last_url = None | |
| _url_expiration = 0 | |
| _extra_arguments = None | |
| device: Device = None | |
| def __init__(self, hass: HomeAssistant): | |
| super().__init__() | |
| self._supported_features = CameraEntityFeature(0) | |
| self.access_tokens = collections.deque(self.access_tokens, 12 * 2) | |
| self._manager = hass.data.get(DATA_FFMPEG) | |
| # http://ffmpeg.org/ffmpeg-all.html | |
| self._ffmpeg_options = '' | |
| self._segment_iv_hex = urandom(16).hex() | |
| self._segment_iv_b64 = base64.b64encode(bytes.fromhex(self._segment_iv_hex)).decode() | |
| if not hasattr(self, '_attr_extra_state_attributes'): | |
| self._attr_extra_state_attributes = {} | |
| def brand(self): | |
| return self.device_info.get('manufacturer') | |
| def is_doorbell(self): | |
| if '.lock.' in self.model: | |
| return True | |
| service = getattr(self, '_miot_service', None) | |
| if service and service.name in ['video_doorbell']: | |
| return True | |
| return False | |
| async def image_source(self, **kwargs): | |
| raise NotImplementedError() | |
| async def async_camera_image(self, width=None, height=None): | |
| url = await self.image_source() | |
| if url: | |
| if '-i ' not in str(url): | |
| url = f'-i "{url}"' | |
| self._last_image = await async_get_image( | |
| self.hass, | |
| f'{self._ffmpeg_options or ""} {url}'.strip(), | |
| extra_cmd=self._extra_arguments, | |
| width=width, | |
| height=height, | |
| ) | |
| return self._last_image | |
| async def handle_async_mjpeg_stream(self, request): | |
| if not self.is_on: | |
| _LOGGER.debug('%s: camera is off. %s', self.name, self._state_attrs) | |
| return | |
| url = await self.stream_source() | |
| if not url: | |
| _LOGGER.debug('%s: stream source is empty. %s', self.name, self._state_attrs) | |
| return | |
| if '-i ' not in str(url): | |
| url = f'-i "{url}"' | |
| stream = CameraMjpeg(self._manager.binary) | |
| await stream.open_camera( | |
| f'{self._ffmpeg_options or ""} {url}'.strip(), | |
| extra_cmd=self._extra_arguments, | |
| ) | |
| try: | |
| stream_reader = await stream.get_reader() | |
| return await async_aiohttp_proxy_stream( | |
| self.hass, | |
| request, | |
| stream_reader, | |
| self._manager.ffmpeg_stream_content_type, | |
| timeout=60, | |
| ) | |
| finally: | |
| try: | |
| await stream.close() | |
| except BrokenPipeError: | |
| _LOGGER.error('%s: Got BrokenPipeError when close stream: %s', self.name, url) | |
| async def _async_log_stderr_stream(self, stderr_reader): | |
| """Log output from ffmpeg.""" | |
| while True: | |
| line = await stderr_reader.readline() | |
| if line == b'': | |
| return | |
| _LOGGER.info('%s: ffmpeg stderr: %s', self.name, line.rstrip()) | |
| async def get_alarm_playlist(self, begin, ended=None, limit=2): | |
| cloud = self.device.cloud | |
| if not cloud: | |
| return None | |
| api = cloud.get_api_by_host('business.smartcamera.api.io.mi.com', 'miot/camera/app/v1/alarm/playlist/limit') | |
| rqd = { | |
| 'did': self.device.did, | |
| 'region': str(cloud.default_server).upper(), | |
| 'language': locale.getlocale()[0], | |
| 'beginTime': begin, | |
| 'endTime': ended or int(time.time() * 1000 + 999), | |
| 'limit': limit, | |
| } | |
| rdt = await cloud.async_request_api(api, rqd, method='GET', crypt=True) or {} | |
| rls = rdt.get('data', {}).get('playUnits') or [] | |
| adt = {} | |
| if rls: | |
| fst = rls[0] or {} | |
| tim = fst.pop('createTime', 0) / 1000 | |
| adt = { | |
| 'motion_video_time': f'{datetime.fromtimestamp(tim)}', | |
| 'motion_video_type': ','.join(fst.get('tags') or []), | |
| 'motion_video_latest': fst, | |
| } | |
| else: | |
| self.log.warning('Camera alarm playlist is empty. %s', rdt) | |
| return adt | |
| async def get_alarm_eventlist(self, begin, ended=None, doorbell=False, limit=2): | |
| cloud = self.device.cloud | |
| if not cloud: | |
| return None | |
| api = cloud.get_api_by_host('business.smartcamera.api.io.mi.com', 'common/app/get/eventlist') | |
| rqd = { | |
| 'did': self.device.did, | |
| 'model': self.model, | |
| 'doorBell': doorbell, | |
| 'eventType': 'Default', | |
| 'needMerge': True, | |
| 'sortType': 'DESC', | |
| 'region': str(cloud.default_server).upper(), | |
| 'language': locale.getlocale()[0], | |
| 'beginTime': begin, | |
| 'endTime': ended or int(time.time() * 1000 + 999), | |
| 'limit': limit, | |
| } | |
| rdt = await cloud.async_request_api(api, rqd, method='GET', crypt=True) or {} | |
| rls = rdt.get('data', {}).get('thirdPartPlayUnits') or [] | |
| adt = {} | |
| if rls: | |
| fst = rls[0] or {} | |
| tim = fst.pop('createTime', 0) / 1000 | |
| adt = { | |
| 'motion_video_time': f'{datetime.fromtimestamp(tim)}', | |
| 'motion_video_type': fst.get('eventType'), | |
| 'motion_video_latest': fst, | |
| } | |
| else: | |
| self.log.info('Camera events is empty. %s', rdt) | |
| return adt | |
| def get_alarm_m3u8_url(self, fileId, isAlarm=False, videoCodec='H265'): | |
| cloud = self.device.cloud | |
| if not cloud or not fileId: | |
| return None | |
| pms = { | |
| 'did': str(self.device.did), | |
| 'model': self.device_info.get('model'), | |
| 'fileId': fileId, | |
| 'isAlarm': not not isAlarm, | |
| 'videoCodec': videoCodec, | |
| } | |
| api = cloud.get_api_by_host('business.smartcamera.api.io.mi.com', 'common/app/m3u8') | |
| pms = cloud.rc4_params('GET', api, {'data': cloud.json_encode(pms)}) | |
| pms['yetAnotherServiceToken'] = cloud.service_token | |
| return f'{api}?{urlencode(pms)}' | |
| def get_alarm_image_address(self, fileId, storeId, crypto=False): | |
| cloud = self.device.cloud | |
| if not (cloud and fileId and storeId): | |
| return None | |
| dat = { | |
| 'did': str(self.device.did), | |
| 'fileId': fileId, | |
| 'stoId': storeId, | |
| 'segmentIv': self._segment_iv_b64, | |
| } | |
| api = cloud.get_api_by_host('processor.smartcamera.api.io.mi.com', 'miot/camera/app/v1/img') | |
| pms = cloud.rc4_params('GET', api, {'data': cloud.json_encode(dat)}) | |
| pms['yetAnotherServiceToken'] = cloud.service_token | |
| url = f'{api}?{urlencode(pms)}' | |
| if crypto: | |
| key = base64.b64decode(cloud.ssecurity).hex() | |
| url = f'-decryption_key {key} -decryption_iv {self._segment_iv_hex} -i "crypto+{url}"' | |
| return url | |
| class CameraEntity(XEntity, BaseCameraEntity): | |
| _attr_should_poll = True | |
| _attr_camera_image = None | |
| _attr_stream_source = None | |
| _last_motion_time = None | |
| def on_init(self): | |
| BaseCameraEntity.__init__(self, self.hass) | |
| self._attr_brand = self.device_info.get('manufacturer') | |
| self._attr_model = self.device_info.get('model') | |
| async def async_added_to_hass(self): | |
| await super().async_added_to_hass() | |
| if self._attr_should_poll: | |
| await self.async_update_ha_state(True) | |
| def get_state(self) -> dict: | |
| return {} | |
| def set_state(self, data: dict): | |
| if 'motion_video_latest' in self.device.props: | |
| self._attr_available = True | |
| self._attr_should_poll = False | |
| self.update_motion_video(self.device.props) | |
| async def image_source(self): | |
| return self._attr_camera_image | |
| async def stream_source(self): | |
| return self._attr_stream_source | |
| def update_motion_video(self, data: dict): | |
| tim = data.get('motion_video_time') | |
| if self._last_motion_time == tim: | |
| return | |
| self._last_motion_time = tim | |
| self._attr_extra_state_attributes.update({ | |
| 'motion_video_time': tim, | |
| 'motion_video_type': data.get('motion_video_type'), | |
| }) | |
| if adt := data.get('motion_video_latest'): | |
| if fid := adt.get('fileId'): | |
| self._attr_camera_image = self.get_alarm_image_address(fid, adt.get('imgStoreId'), True) | |
| self._attr_stream_source = self.get_alarm_m3u8_url(fid, adt.get('isAlarm')) | |
| self._attr_extra_state_attributes.update({ | |
| 'stream_address': self._attr_stream_source, | |
| }) | |
| async def async_update(self): | |
| adt = None | |
| stm = int(time.time() - 86400 * 7) * 1000 | |
| if not self.device.cloud: | |
| pass | |
| elif self.custom_config_bool('miio_event_human_visit_details_template'): | |
| await self.device.update_miio_cloud_records() | |
| elif self.custom_config_bool('use_alarm_playlist'): | |
| adt = await self.get_alarm_playlist(stm) | |
| else: | |
| adt = await self.get_alarm_eventlist(stm, None, self.is_doorbell) | |
| if adt: | |
| self.log.debug('Camera alarm data: %s', adt) | |
| self._attr_available = True | |
| self.update_motion_video(adt) | |
| XEntity.CLS[ENTITY_DOMAIN] = CameraEntity | |
| class MiotCameraEntity(MiotToggleEntity, BaseCameraEntity): | |
| _srv_stream = None | |
| _act_start_stream = None | |
| _act_stop_stream = None | |
| _prop_stream_address = None | |
| _prop_expiration_time = None | |
| _prop_motion_tracking = None | |
| _stream_refresh_unsub = None | |
| _motion_entity = None | |
| _motion_enable = None | |
| _use_motion_stream = False | |
| _sub_motion_stream = False | |
| def __init__(self, hass: HomeAssistant, config: dict, miot_service: MiotService): | |
| super().__init__(miot_service, config=config, logger=_LOGGER) | |
| BaseCameraEntity.__init__(self, hass) | |
| if self._prop_power: | |
| self._supported_features |= CameraEntityFeature.ON_OFF | |
| if miot_service: | |
| self._prop_motion_tracking = miot_service.bool_property('motion_detection', 'motion_tracking') | |
| async def async_added_to_hass(self): | |
| await super().async_added_to_hass() | |
| sls = ['camera_stream_for_google_home', 'camera_stream_for_amazon_alexa'] | |
| if self.custom_config_bool('use_rtsp_stream'): | |
| sls.reverse() | |
| for s in sls: | |
| if not self._miot_service: | |
| break | |
| srv = self._miot_service.spec.get_service(s) | |
| if not srv: | |
| continue | |
| act = srv.get_action('start_hls_stream', 'start_rtsp_stream') | |
| if act: | |
| self._srv_stream = srv | |
| self._act_start_stream = act | |
| self._act_stop_stream = srv.get_action('stop_stream') | |
| self._prop_stream_address = srv.get_property('stream_address') | |
| self._prop_expiration_time = srv.get_property('expiration_time') | |
| break | |
| if self._prop_stream_address: | |
| self._supported_features |= CameraEntityFeature.STREAM | |
| self._sub_motion_stream = True | |
| elif self._miot_service.name in ['camera_control'] or self.is_doorbell: | |
| if self.custom_config_bool('use_motion_stream'): | |
| pass | |
| elif self.custom_config_bool('sub_motion_stream'): | |
| pass | |
| else: | |
| self._use_motion_stream = True | |
| def should_poll(self): | |
| return True | |
| def state(self): # noqa | |
| if self.is_recording: | |
| return CameraState.RECORDING | |
| if self.is_streaming: | |
| return CameraState.STREAMING | |
| return STATE_IDLE | |
| async def async_update(self): | |
| self._state_attrs.pop('motion_video_latest', None) # remove | |
| await super().async_update() | |
| if not self._available: | |
| return | |
| self._motion_enable = self.custom_config_bool('use_motion_stream', self._use_motion_stream) | |
| add_cameras = self._add_entities.get(ENTITY_DOMAIN) | |
| if not self._motion_entity \ | |
| and add_cameras \ | |
| and self.custom_config_bool('sub_motion_stream', self._sub_motion_stream): | |
| self._motion_entity = MotionCameraEntity(self, self.hass) | |
| self._subs['motion_event'] = self._motion_entity | |
| add_cameras([self._motion_entity], update_before_add=True) | |
| adt = None | |
| stm = int(time.time() - 86400 * 7) * 1000 | |
| if not self._motion_enable and not self._motion_entity: | |
| pass | |
| elif 'motion_video_latest' in self._state_attrs: | |
| adt = { | |
| 'motion_video_updated': 1, | |
| } | |
| elif not self.xiaomi_cloud: | |
| pass | |
| elif self.custom_config_bool('use_alarm_playlist'): | |
| adt = await self.get_alarm_playlist(stm) | |
| else: | |
| adt = await self.get_alarm_eventlist(stm, None, self.is_doorbell) | |
| if adt: | |
| self._supported_features |= CameraEntityFeature.STREAM | |
| await self.async_update_attrs(adt) | |
| if self._motion_enable: | |
| await self.async_update_attrs(self.motion_event_attributes) | |
| if self._motion_entity: | |
| await self.hass.async_add_executor_job(self._motion_entity.update) | |
| def is_on(self): | |
| if self._prop_power: | |
| return self._prop_power.from_device(self.device) and True | |
| return True | |
| async def stream_source(self, **kwargs): | |
| fun = self.async_get_stream_address() | |
| if self._motion_enable: | |
| kwargs['crypto'] = True | |
| fun = self.hass.async_add_executor_job(partial(self.get_motion_stream_address, **kwargs)) | |
| idx = self.custom_config_integer('motion_stream_slice') | |
| if idx is not None: | |
| kwargs['index'] = idx | |
| fun = self.hass.async_add_executor_job(partial(self.get_motion_stream_slice_video), **kwargs) | |
| return await fun | |
| async def image_source(self, **kwargs): | |
| if self._motion_enable: | |
| kwargs['crypto'] = True | |
| return self.get_motion_image_address(**kwargs) | |
| return await self.stream_source() | |
| async def async_get_stream_address(self, **kwargs): | |
| now = time.time() | |
| if now >= self._url_expiration: | |
| self._last_url = None | |
| _LOGGER.debug('%s: camera stream: %s expired: %s', self.name_model, self._last_url, self._url_expiration) | |
| result = {} | |
| if not self._act_start_stream: | |
| self.update_attrs({ | |
| 'miot_error': 'Nonsupport start hls/rstp stream via miot-spec', | |
| }) | |
| elif not self._last_url: | |
| updater = 'lan' | |
| try: | |
| vav = self.custom_config_integer('video_attribute') | |
| vap = self._srv_stream.get_property('video_attribute') | |
| if vav is None and vap and vap.value_list: | |
| vav = (vap.value_list.pop(0) or {}).get('value') | |
| if self.xiaomi_cloud: | |
| if self._act_stop_stream: | |
| await self.async_call_action(self._act_stop_stream) | |
| result = await self.async_call_action(self._act_start_stream, [] if vav is None else [vav]) or {} | |
| updater = 'cloud' | |
| if isinstance(result, dict): | |
| _LOGGER.debug('%s: Get miot camera stream from %s: %s', self.name_model, updater, result) | |
| else: | |
| _LOGGER.warning('%s: Get miot camera stream error from %s: %s', self.name_model, updater, result) | |
| result = {} | |
| except MiCloudException as exc: | |
| _LOGGER.error('%s: Get miot camera stream from %s failed: %s', self.name_model, updater, exc) | |
| odt = self._act_start_stream.out_results(result.get('out')) or { | |
| 'stream_address': '', | |
| } | |
| self._url_expiration = 0 | |
| if self._prop_expiration_time: | |
| self._url_expiration = int(self._prop_expiration_time.from_dict(odt) or 0) / 1000 | |
| if self._url_expiration: | |
| self._url_expiration -= 10 | |
| else: | |
| self._url_expiration = now + 60 * 4.5 | |
| if self._prop_stream_address: | |
| self._last_url = self._prop_stream_address.from_dict(odt) | |
| self.schedule_update_ha_state() | |
| self.async_check_stream_address(self._last_url) | |
| if not kwargs.get('scheduled') or self.custom_config('keep_streaming'): | |
| self._schedule_stream_refresh() | |
| odt['expire_at'] = f'{datetime.fromtimestamp(self._url_expiration)}' | |
| self.update_attrs(odt) | |
| self._attr_is_streaming = self._last_url and True | |
| if self._attr_is_streaming: | |
| self.update_attrs({ | |
| 'miot_error': None, | |
| }) | |
| return self._last_url | |
| def async_check_stream_address(self, url): | |
| if not url or self.custom_config_bool('disable_check_stream'): | |
| return False | |
| res = requests.head(url) | |
| if res.status_code > 200: | |
| self.update_attrs({ | |
| 'stream_http_status': res.status_code, | |
| 'stream_http_reason': res.reason, | |
| }) | |
| _LOGGER.warning( | |
| '%s: stream address status invalid: %s (%s)', | |
| self.name, | |
| res.status_code, | |
| res.reason, | |
| ) | |
| return False | |
| return True | |
| async def _handle_stream_refresh(self, now, *_): | |
| self._stream_refresh_unsub = None | |
| await self.stream_source(scheduled=True) | |
| def _schedule_stream_refresh(self): | |
| if self._stream_refresh_unsub is not None: | |
| self._stream_refresh_unsub() | |
| self._stream_refresh_unsub = async_track_point_in_utc_time( | |
| self.hass, | |
| self._handle_stream_refresh, # noqa | |
| datetime.fromtimestamp(self._url_expiration), | |
| ) | |
| def motion_event_attributes(self): | |
| return { | |
| 'motion_video_time': self._state_attrs.get('motion_video_time'), | |
| 'motion_video_type': self._state_attrs.get('motion_video_type'), | |
| 'stream_address': self.get_motion_stream_address(), | |
| 'image_address': self.get_motion_image_address(), | |
| } | |
| def get_motion_stream_address(self, **kwargs): | |
| mic = self.xiaomi_cloud | |
| if not mic: | |
| _LOGGER.info('%s: camera does not have cloud.', self.name) | |
| return None | |
| mvd = self._state_attrs.get('motion_video_latest') or {} | |
| fid = mvd.get('fileId') | |
| if not fid: | |
| _LOGGER.info('%s: camera does not have motion file in cloud.', self.name) | |
| return None | |
| url = self.get_alarm_m3u8_url(fid, mvd.get('isAlarm')) | |
| _LOGGER.debug('%s: Got stream url: %s', self.name_model, url) | |
| return url | |
| def get_motion_video_address(self, **kwargs): | |
| mic = self.xiaomi_cloud | |
| if not mic: | |
| _LOGGER.info('%s: camera does not have cloud.', self.name) | |
| return None | |
| mvd = self._state_attrs.get('motion_video_latest') or {} | |
| fid = mvd.get('fileId') | |
| vid = mvd.get('videoStoreId') | |
| if not fid or not vid: | |
| _LOGGER.info('%s: camera does not have motion video in cloud.', self.name) | |
| return None | |
| dat = { | |
| 'did': str(self.miot_did), | |
| 'fileId': fid, | |
| 'stoId': vid, | |
| 'segmentIv': self._segment_iv_b64, | |
| } | |
| api = mic.get_api_by_host('processor.smartcamera.api.io.mi.com', 'miot/camera/app/v1/mp4') | |
| pms = mic.rc4_params('GET', api, {'data': mic.json_encode(dat)}) | |
| pms['yetAnotherServiceToken'] = mic.service_token | |
| url = f'{api}?{urlencode(pms)}' | |
| _LOGGER.debug('%s: Got video url: %s', self.name_model, url) | |
| if kwargs.get('debug'): | |
| req = requests.get(url) | |
| if float(req.headers.get('x-xiaomi-status-code', 200)) >= 400: | |
| try: | |
| signed_nonce = mic.signed_nonce(pms['_nonce']) | |
| rdt = json.loads(MiotCloud.decrypt_data(signed_nonce, req.text).decode()) | |
| _LOGGER.info('%s: video stream content: %s', self.name_model, rdt) | |
| except (TypeError, ValueError): | |
| pass | |
| if kwargs.get('crypto'): | |
| key = base64.b64decode(mic.ssecurity).hex() | |
| url = f'-decryption_key {key} -decryption_iv {self._segment_iv_hex} -i "crypto+{url}"' | |
| return url | |
| def get_motion_stream_slice_video(self, **kwargs): | |
| url = self.get_motion_stream_address() | |
| if not url: | |
| _LOGGER.info('%s: camera does not have motion stream in cloud.', self.name) | |
| return None | |
| req = requests.get(url) | |
| if float(req.headers.get('x-xiaomi-status-code', 200)) >= 400: | |
| _LOGGER.warning('%s: camera motion stream with a failed http code: %s', self.name_model, req) | |
| return url | |
| aes_key = None | |
| aes__iv = None | |
| mat = re.search(r'AES-128,\s*URI="?(https?://[^",]+)"?,\s*IV=(?:0x)?(\w+)', req.text) | |
| if mat: | |
| aes_key, aes__iv = mat.groups() | |
| mat = re.findall(r'[\r\n](https?://[^\r\n]+)', req.text) | |
| idx = kwargs.get('index', -1) | |
| mp4 = mat.pop(idx) if mat else None | |
| if mp4 and aes_key: | |
| req = requests.get(aes_key) | |
| key = req.content.hex() | |
| mp4 = f'-decryption_key {key} -decryption_iv {aes__iv} -i "crypto+{mp4}"' | |
| _LOGGER.debug('%s: Got video url: %s', self.name_model, mp4) | |
| return mp4 | |
| def get_motion_image_address(self, **kwargs): | |
| mic = self.xiaomi_cloud | |
| if not mic: | |
| _LOGGER.info('%s: camera does not have cloud.', self.name) | |
| return None | |
| mvd = self._state_attrs.get('motion_video_latest') or {} | |
| fid = mvd.get('fileId') | |
| iid = mvd.get('imgStoreId') | |
| if not fid or not iid: | |
| _LOGGER.info('%s: camera does not have motion image in cloud.', self.name) | |
| return None | |
| dat = { | |
| 'did': str(self.miot_did), | |
| 'fileId': fid, | |
| 'stoId': iid, | |
| 'segmentIv': self._segment_iv_b64, | |
| } | |
| api = mic.get_api_by_host('processor.smartcamera.api.io.mi.com', 'miot/camera/app/v1/img') | |
| pms = mic.rc4_params('GET', api, {'data': mic.json_encode(dat)}) | |
| pms['yetAnotherServiceToken'] = mic.service_token | |
| url = f'{api}?{urlencode(pms)}' | |
| _LOGGER.debug('%s: Got image url: %s', self.name_model, url) | |
| if kwargs.get('crypto'): | |
| key = base64.b64decode(mic.ssecurity).hex() | |
| url = f'-decryption_key {key} -decryption_iv {self._segment_iv_hex} -i "crypto+{url}"' | |
| return url | |
| def motion_detection_enabled(self): | |
| if self._prop_motion_tracking: | |
| return self._prop_motion_tracking.from_device(self.device) | |
| return None | |
| async def async_enable_motion_detection(self): | |
| if self._prop_motion_tracking: | |
| return await self.async_set_property(self._prop_motion_tracking, True) | |
| return False | |
| async def async_disable_motion_detection(self): | |
| if self._prop_motion_tracking: | |
| return await self.async_set_property(self._prop_motion_tracking, False) | |
| return False | |
| class MotionCameraEntity(BaseSubEntity, BaseCameraEntity): | |
| def __init__(self, parent, hass: HomeAssistant, option=None): | |
| super().__init__(parent, 'motion_event', option, domain=ENTITY_DOMAIN) | |
| BaseCameraEntity.__init__(self, hass) | |
| self._available = True | |
| self._supported_features |= CameraEntityFeature.STREAM | |
| def update(self, data=None): | |
| super().update(data) | |
| self._available = not not self.parent_attributes.get('motion_video_latest') | |
| if not self._available: | |
| return | |
| self.update_attrs(self._parent.motion_event_attributes, update_parent=False) | |
| async def stream_source(self, **kwargs): | |
| kwargs['crypto'] = True | |
| return await self.hass.async_add_executor_job( | |
| partial(self._parent.get_motion_stream_address, **kwargs) | |
| ) | |
| async def image_source(self, **kwargs): | |
| kwargs['crypto'] = True | |
| return self._parent.get_motion_image_address(**kwargs) | |
Xet Storage Details
- Size:
- 27.6 kB
- Xet hash:
- b7ff850845ff25a38d016e3169fa2db153451ef88cf6b49507fcc72b36632204
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.