初始化config文件
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
"""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 = {}
|
||||
|
||||
@property
|
||||
def brand(self):
|
||||
return self.device_info.get('manufacturer')
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
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)
|
||||
|
||||
@property
|
||||
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),
|
||||
)
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
@property
|
||||
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)
|
||||
Reference in New Issue
Block a user