484 lines
17 KiB
Python
484 lines
17 KiB
Python
"""Support for Amcrest IP cameras."""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
import logging
|
|
from urllib3.exceptions import HTTPError
|
|
|
|
from amcrest import AmcrestError
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.camera import (
|
|
Camera, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, SUPPORT_STREAM)
|
|
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
|
from homeassistant.const import (
|
|
CONF_NAME, STATE_ON, STATE_OFF)
|
|
from homeassistant.helpers.aiohttp_client import (
|
|
async_aiohttp_proxy_stream, async_aiohttp_proxy_web,
|
|
async_get_clientsession)
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|
|
|
from .const import (
|
|
CAMERA_WEB_SESSION_TIMEOUT, CAMERAS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
|
|
from .helpers import log_update_error, service_signal
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SCAN_INTERVAL = timedelta(seconds=15)
|
|
|
|
STREAM_SOURCE_LIST = [
|
|
'snapshot',
|
|
'mjpeg',
|
|
'rtsp',
|
|
]
|
|
|
|
_SRV_EN_REC = 'enable_recording'
|
|
_SRV_DS_REC = 'disable_recording'
|
|
_SRV_EN_AUD = 'enable_audio'
|
|
_SRV_DS_AUD = 'disable_audio'
|
|
_SRV_EN_MOT_REC = 'enable_motion_recording'
|
|
_SRV_DS_MOT_REC = 'disable_motion_recording'
|
|
_SRV_GOTO = 'goto_preset'
|
|
_SRV_CBW = 'set_color_bw'
|
|
_SRV_TOUR_ON = 'start_tour'
|
|
_SRV_TOUR_OFF = 'stop_tour'
|
|
|
|
_ATTR_PRESET = 'preset'
|
|
_ATTR_COLOR_BW = 'color_bw'
|
|
|
|
_CBW_COLOR = 'color'
|
|
_CBW_AUTO = 'auto'
|
|
_CBW_BW = 'bw'
|
|
_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
|
|
|
|
_SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({
|
|
vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
|
})
|
|
_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({
|
|
vol.Required(_ATTR_COLOR_BW): vol.In(_CBW),
|
|
})
|
|
|
|
CAMERA_SERVICES = {
|
|
_SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, 'async_enable_recording', ()),
|
|
_SRV_DS_REC: (CAMERA_SERVICE_SCHEMA, 'async_disable_recording', ()),
|
|
_SRV_EN_AUD: (CAMERA_SERVICE_SCHEMA, 'async_enable_audio', ()),
|
|
_SRV_DS_AUD: (CAMERA_SERVICE_SCHEMA, 'async_disable_audio', ()),
|
|
_SRV_EN_MOT_REC: (
|
|
CAMERA_SERVICE_SCHEMA, 'async_enable_motion_recording', ()),
|
|
_SRV_DS_MOT_REC: (
|
|
CAMERA_SERVICE_SCHEMA, 'async_disable_motion_recording', ()),
|
|
_SRV_GOTO: (_SRV_GOTO_SCHEMA, 'async_goto_preset', (_ATTR_PRESET,)),
|
|
_SRV_CBW: (_SRV_CBW_SCHEMA, 'async_set_color_bw', (_ATTR_COLOR_BW,)),
|
|
_SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, 'async_start_tour', ()),
|
|
_SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, 'async_stop_tour', ()),
|
|
}
|
|
|
|
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
|
|
|
|
|
|
async def async_setup_platform(hass, config, async_add_entities,
|
|
discovery_info=None):
|
|
"""Set up an Amcrest IP Camera."""
|
|
if discovery_info is None:
|
|
return
|
|
|
|
name = discovery_info[CONF_NAME]
|
|
device = hass.data[DATA_AMCREST][DEVICES][name]
|
|
async_add_entities([
|
|
AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True)
|
|
|
|
|
|
class AmcrestCam(Camera):
|
|
"""An implementation of an Amcrest IP camera."""
|
|
|
|
def __init__(self, name, device, ffmpeg):
|
|
"""Initialize an Amcrest camera."""
|
|
super().__init__()
|
|
self._name = name
|
|
self._api = device.api
|
|
self._ffmpeg = ffmpeg
|
|
self._ffmpeg_arguments = device.ffmpeg_arguments
|
|
self._stream_source = device.stream_source
|
|
self._resolution = device.resolution
|
|
self._token = self._auth = device.authentication
|
|
self._control_light = device.control_light
|
|
self._is_recording = False
|
|
self._motion_detection_enabled = None
|
|
self._brand = None
|
|
self._model = None
|
|
self._audio_enabled = None
|
|
self._motion_recording_enabled = None
|
|
self._color_bw = None
|
|
self._rtsp_url = None
|
|
self._snapshot_lock = asyncio.Lock()
|
|
self._unsub_dispatcher = []
|
|
self._update_succeeded = False
|
|
|
|
async def async_camera_image(self):
|
|
"""Return a still image response from the camera."""
|
|
available = self.available
|
|
if not available or not self.is_on:
|
|
_LOGGER.warning(
|
|
'Attempt to take snaphot when %s camera is %s', self.name,
|
|
'offline' if not available else 'off')
|
|
return None
|
|
async with self._snapshot_lock:
|
|
try:
|
|
# Send the request to snap a picture and return raw jpg data
|
|
response = await self.hass.async_add_executor_job(
|
|
self._api.snapshot)
|
|
return response.data
|
|
except (AmcrestError, HTTPError) as error:
|
|
log_update_error(
|
|
_LOGGER, 'get image from', self.name, 'camera', error)
|
|
return None
|
|
|
|
async def handle_async_mjpeg_stream(self, request):
|
|
"""Return an MJPEG stream."""
|
|
# The snapshot implementation is handled by the parent class
|
|
if self._stream_source == 'snapshot':
|
|
return await super().handle_async_mjpeg_stream(request)
|
|
|
|
if not self.available:
|
|
_LOGGER.warning(
|
|
'Attempt to stream %s when %s camera is offline',
|
|
self._stream_source, self.name)
|
|
return None
|
|
|
|
if self._stream_source == 'mjpeg':
|
|
# stream an MJPEG image stream directly from the camera
|
|
websession = async_get_clientsession(self.hass)
|
|
streaming_url = self._api.mjpeg_url(typeno=self._resolution)
|
|
stream_coro = websession.get(
|
|
streaming_url, auth=self._token,
|
|
timeout=CAMERA_WEB_SESSION_TIMEOUT)
|
|
|
|
return await async_aiohttp_proxy_web(
|
|
self.hass, request, stream_coro)
|
|
|
|
# streaming via ffmpeg
|
|
from haffmpeg.camera import CameraMjpeg
|
|
|
|
streaming_url = self._rtsp_url
|
|
stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
|
|
await stream.open_camera(
|
|
streaming_url, extra_cmd=self._ffmpeg_arguments)
|
|
|
|
try:
|
|
stream_reader = await stream.get_reader()
|
|
return await async_aiohttp_proxy_stream(
|
|
self.hass, request, stream_reader,
|
|
self._ffmpeg.ffmpeg_stream_content_type)
|
|
finally:
|
|
await stream.close()
|
|
|
|
# Entity property overrides
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Return True if entity has to be polled for state.
|
|
|
|
False if entity pushes its state to HA.
|
|
"""
|
|
return True
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of this camera."""
|
|
return self._name
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return the Amcrest-specific camera state attributes."""
|
|
attr = {}
|
|
if self._audio_enabled is not None:
|
|
attr['audio'] = _BOOL_TO_STATE.get(self._audio_enabled)
|
|
if self._motion_recording_enabled is not None:
|
|
attr['motion_recording'] = _BOOL_TO_STATE.get(
|
|
self._motion_recording_enabled)
|
|
if self._color_bw is not None:
|
|
attr[_ATTR_COLOR_BW] = self._color_bw
|
|
return attr
|
|
|
|
@property
|
|
def available(self):
|
|
"""Return True if entity is available."""
|
|
return self._api.available
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Return supported features."""
|
|
return SUPPORT_ON_OFF | SUPPORT_STREAM
|
|
|
|
# Camera property overrides
|
|
|
|
@property
|
|
def is_recording(self):
|
|
"""Return true if the device is recording."""
|
|
return self._is_recording
|
|
|
|
@property
|
|
def brand(self):
|
|
"""Return the camera brand."""
|
|
return self._brand
|
|
|
|
@property
|
|
def motion_detection_enabled(self):
|
|
"""Return the camera motion detection status."""
|
|
return self._motion_detection_enabled
|
|
|
|
@property
|
|
def model(self):
|
|
"""Return the camera model."""
|
|
return self._model
|
|
|
|
async def stream_source(self):
|
|
"""Return the source of the stream."""
|
|
return self._rtsp_url
|
|
|
|
@property
|
|
def is_on(self):
|
|
"""Return true if on."""
|
|
return self.is_streaming
|
|
|
|
# Other Entity method overrides
|
|
|
|
async def async_on_demand_update(self):
|
|
"""Update state."""
|
|
self.async_schedule_update_ha_state(True)
|
|
|
|
async def async_added_to_hass(self):
|
|
"""Subscribe to signals and add camera to list."""
|
|
for service, params in CAMERA_SERVICES.items():
|
|
self._unsub_dispatcher.append(async_dispatcher_connect(
|
|
self.hass,
|
|
service_signal(service, self.entity_id),
|
|
getattr(self, params[1])))
|
|
self._unsub_dispatcher.append(async_dispatcher_connect(
|
|
self.hass, service_signal(SERVICE_UPDATE, self._name),
|
|
self.async_on_demand_update))
|
|
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
|
|
|
|
async def async_will_remove_from_hass(self):
|
|
"""Remove camera from list and disconnect from signals."""
|
|
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
|
|
for unsub_dispatcher in self._unsub_dispatcher:
|
|
unsub_dispatcher()
|
|
|
|
def update(self):
|
|
"""Update entity status."""
|
|
if not self.available or self._update_succeeded:
|
|
if not self.available:
|
|
self._update_succeeded = False
|
|
return
|
|
_LOGGER.debug('Updating %s camera', self.name)
|
|
try:
|
|
if self._brand is None:
|
|
resp = self._api.vendor_information.strip()
|
|
if resp.startswith('vendor='):
|
|
self._brand = resp.split('=')[-1]
|
|
else:
|
|
self._brand = 'unknown'
|
|
if self._model is None:
|
|
resp = self._api.device_type.strip()
|
|
if resp.startswith('type='):
|
|
self._model = resp.split('=')[-1]
|
|
else:
|
|
self._model = 'unknown'
|
|
self.is_streaming = self._api.video_enabled
|
|
self._is_recording = self._api.record_mode == 'Manual'
|
|
self._motion_detection_enabled = (
|
|
self._api.is_motion_detector_on())
|
|
self._audio_enabled = self._api.audio_enabled
|
|
self._motion_recording_enabled = (
|
|
self._api.is_record_on_motion_detection())
|
|
self._color_bw = _CBW[self._api.day_night_color]
|
|
self._rtsp_url = self._api.rtsp_url(typeno=self._resolution)
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'get', self.name, 'camera attributes', error)
|
|
self._update_succeeded = False
|
|
else:
|
|
self._update_succeeded = True
|
|
|
|
# Other Camera method overrides
|
|
|
|
def turn_off(self):
|
|
"""Turn off camera."""
|
|
self._enable_video_stream(False)
|
|
|
|
def turn_on(self):
|
|
"""Turn on camera."""
|
|
self._enable_video_stream(True)
|
|
|
|
def enable_motion_detection(self):
|
|
"""Enable motion detection in the camera."""
|
|
self._enable_motion_detection(True)
|
|
|
|
def disable_motion_detection(self):
|
|
"""Disable motion detection in camera."""
|
|
self._enable_motion_detection(False)
|
|
|
|
# Additional Amcrest Camera service methods
|
|
|
|
async def async_enable_recording(self):
|
|
"""Call the job and enable recording."""
|
|
await self.hass.async_add_executor_job(self._enable_recording, True)
|
|
|
|
async def async_disable_recording(self):
|
|
"""Call the job and disable recording."""
|
|
await self.hass.async_add_executor_job(self._enable_recording, False)
|
|
|
|
async def async_enable_audio(self):
|
|
"""Call the job and enable audio."""
|
|
await self.hass.async_add_executor_job(self._enable_audio, True)
|
|
|
|
async def async_disable_audio(self):
|
|
"""Call the job and disable audio."""
|
|
await self.hass.async_add_executor_job(self._enable_audio, False)
|
|
|
|
async def async_enable_motion_recording(self):
|
|
"""Call the job and enable motion recording."""
|
|
await self.hass.async_add_executor_job(self._enable_motion_recording,
|
|
True)
|
|
|
|
async def async_disable_motion_recording(self):
|
|
"""Call the job and disable motion recording."""
|
|
await self.hass.async_add_executor_job(self._enable_motion_recording,
|
|
False)
|
|
|
|
async def async_goto_preset(self, preset):
|
|
"""Call the job and move camera to preset position."""
|
|
await self.hass.async_add_executor_job(self._goto_preset, preset)
|
|
|
|
async def async_set_color_bw(self, color_bw):
|
|
"""Call the job and set camera color mode."""
|
|
await self.hass.async_add_executor_job(self._set_color_bw, color_bw)
|
|
|
|
async def async_start_tour(self):
|
|
"""Call the job and start camera tour."""
|
|
await self.hass.async_add_executor_job(self._start_tour, True)
|
|
|
|
async def async_stop_tour(self):
|
|
"""Call the job and stop camera tour."""
|
|
await self.hass.async_add_executor_job(self._start_tour, False)
|
|
|
|
# Methods to send commands to Amcrest camera and handle errors
|
|
|
|
def _enable_video_stream(self, enable):
|
|
"""Enable or disable camera video stream."""
|
|
# Given the way the camera's state is determined by
|
|
# is_streaming and is_recording, we can't leave
|
|
# recording on if video stream is being turned off.
|
|
if self.is_recording and not enable:
|
|
self._enable_recording(False)
|
|
try:
|
|
self._api.video_enabled = enable
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'camera video stream', error)
|
|
else:
|
|
self.is_streaming = enable
|
|
self.schedule_update_ha_state()
|
|
if self._control_light:
|
|
self._enable_light(self._audio_enabled or self.is_streaming)
|
|
|
|
def _enable_recording(self, enable):
|
|
"""Turn recording on or off."""
|
|
# Given the way the camera's state is determined by
|
|
# is_streaming and is_recording, we can't leave
|
|
# video stream off if recording is being turned on.
|
|
if not self.is_streaming and enable:
|
|
self._enable_video_stream(True)
|
|
rec_mode = {'Automatic': 0, 'Manual': 1}
|
|
try:
|
|
self._api.record_mode = rec_mode[
|
|
'Manual' if enable else 'Automatic']
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'camera recording', error)
|
|
else:
|
|
self._is_recording = enable
|
|
self.schedule_update_ha_state()
|
|
|
|
def _enable_motion_detection(self, enable):
|
|
"""Enable or disable motion detection."""
|
|
try:
|
|
self._api.motion_detection = str(enable).lower()
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'camera motion detection', error)
|
|
else:
|
|
self._motion_detection_enabled = enable
|
|
self.schedule_update_ha_state()
|
|
|
|
def _enable_audio(self, enable):
|
|
"""Enable or disable audio stream."""
|
|
try:
|
|
self._api.audio_enabled = enable
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'camera audio stream', error)
|
|
else:
|
|
self._audio_enabled = enable
|
|
self.schedule_update_ha_state()
|
|
if self._control_light:
|
|
self._enable_light(self._audio_enabled or self.is_streaming)
|
|
|
|
def _enable_light(self, enable):
|
|
"""Enable or disable indicator light."""
|
|
try:
|
|
self._api.command(
|
|
'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}'
|
|
.format(str(enable).lower()))
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'indicator light', error)
|
|
|
|
def _enable_motion_recording(self, enable):
|
|
"""Enable or disable motion recording."""
|
|
try:
|
|
self._api.motion_recording = str(enable).lower()
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'enable' if enable else 'disable', self.name,
|
|
'camera motion recording', error)
|
|
else:
|
|
self._motion_recording_enabled = enable
|
|
self.schedule_update_ha_state()
|
|
|
|
def _goto_preset(self, preset):
|
|
"""Move camera position and zoom to preset."""
|
|
try:
|
|
self._api.go_to_preset(
|
|
action='start', preset_point_number=preset)
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'move', self.name,
|
|
'camera to preset {}'.format(preset), error)
|
|
|
|
def _set_color_bw(self, cbw):
|
|
"""Set camera color mode."""
|
|
try:
|
|
self._api.day_night_color = _CBW.index(cbw)
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'set', self.name,
|
|
'camera color mode to {}'.format(cbw), error)
|
|
else:
|
|
self._color_bw = cbw
|
|
self.schedule_update_ha_state()
|
|
|
|
def _start_tour(self, start):
|
|
"""Start camera tour."""
|
|
try:
|
|
self._api.tour(start=start)
|
|
except AmcrestError as error:
|
|
log_update_error(
|
|
_LOGGER, 'start' if start else 'stop', self.name,
|
|
'camera tour', error)
|