Add PTZ support to Foscam camera component (#27238)

* Add PTZ support to Foscam camera component

* Address review comments:

 - Move service to foscam domain
 - Use `dict[key]` for required schema keys or with defaults
 - Fix sync operations in async context
 - Remove excessive logging

* Fix import order

* Move all the initialization to setup_platform and fix motion detection status logic

* Move function dictionary out of the function.

* Change user input to lowercase snake case

* Change user input to lowercase snake case

* Fix service example value

* Omit foscam const module from code coverage tests

* Add myself to foscam codeowners
This commit is contained in:
Sergio Conde Gómez 2019-10-07 13:17:43 +02:00 committed by Franck Nijhof
parent 4124211095
commit f6b8cffeaf
6 changed files with 185 additions and 28 deletions

View File

@ -224,6 +224,7 @@ omit =
homeassistant/components/fortios/device_tracker.py homeassistant/components/fortios/device_tracker.py
homeassistant/components/fortigate/* homeassistant/components/fortigate/*
homeassistant/components/foscam/camera.py homeassistant/components/foscam/camera.py
homeassistant/components/foscam/const.py
homeassistant/components/foursquare/* homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/* homeassistant/components/freebox/*

View File

@ -98,6 +98,7 @@ homeassistant/components/flock/* @fabaff
homeassistant/components/flunearyou/* @bachya homeassistant/components/flunearyou/* @bachya
homeassistant/components/fortigate/* @kifeo homeassistant/components/fortigate/* @kifeo
homeassistant/components/fortios/* @kimfrellsen homeassistant/components/fortios/* @kimfrellsen
homeassistant/components/foscam/* @skgsergio
homeassistant/components/foursquare/* @robbiet480 homeassistant/components/foursquare/* @robbiet480
homeassistant/components/freebox/* @snoof85 homeassistant/components/freebox/* @snoof85
homeassistant/components/fronius/* @nielstron homeassistant/components/fronius/* @nielstron

View File

@ -1,11 +1,26 @@
"""This component provides basic support for Foscam IP cameras.""" """This component provides basic support for Foscam IP cameras."""
import logging import logging
import asyncio
from libpyfoscam import FoscamCamera
import voluptuous as vol import voluptuous as vol
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, SUPPORT_STREAM from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, SUPPORT_STREAM
from homeassistant.const import CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT from homeassistant.const import (
CONF_NAME,
CONF_USERNAME,
CONF_PASSWORD,
CONF_PORT,
ATTR_ENTITY_ID,
)
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_extract_entity_ids
from .const import DOMAIN as FOSCAM_DOMAIN
from .const import DATA as FOSCAM_DATA
from .const import ENTITIES as FOSCAM_ENTITIES
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -15,7 +30,32 @@ CONF_RTSP_PORT = "rtsp_port"
DEFAULT_NAME = "Foscam Camera" DEFAULT_NAME = "Foscam Camera"
DEFAULT_PORT = 88 DEFAULT_PORT = 88
FOSCAM_COMM_ERROR = -8 SERVICE_PTZ = "ptz"
ATTR_MOVEMENT = "movement"
ATTR_TRAVELTIME = "travel_time"
DEFAULT_TRAVELTIME = 0.125
DIR_UP = "up"
DIR_DOWN = "down"
DIR_LEFT = "left"
DIR_RIGHT = "right"
DIR_TOPLEFT = "top_left"
DIR_TOPRIGHT = "top_right"
DIR_BOTTOMLEFT = "bottom_left"
DIR_BOTTOMRIGHT = "bottom_right"
MOVEMENT_ATTRS = {
DIR_UP: "ptz_move_up",
DIR_DOWN: "ptz_move_down",
DIR_LEFT: "ptz_move_left",
DIR_RIGHT: "ptz_move_right",
DIR_TOPLEFT: "ptz_move_top_left",
DIR_TOPRIGHT: "ptz_move_top_right",
DIR_BOTTOMLEFT: "ptz_move_bottom_left",
DIR_BOTTOMRIGHT: "ptz_move_bottom_right",
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
@ -28,44 +68,114 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
} }
) )
SERVICE_PTZ_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_MOVEMENT): vol.In(
[
DIR_UP,
DIR_DOWN,
DIR_LEFT,
DIR_RIGHT,
DIR_TOPLEFT,
DIR_TOPRIGHT,
DIR_BOTTOMLEFT,
DIR_BOTTOMRIGHT,
]
),
vol.Optional(ATTR_TRAVELTIME, default=DEFAULT_TRAVELTIME): cv.small_float,
}
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up a Foscam IP Camera.""" """Set up a Foscam IP Camera."""
add_entities([FoscamCam(config)])
async def async_handle_ptz(service):
"""Handle PTZ service call."""
movement = service.data[ATTR_MOVEMENT]
travel_time = service.data[ATTR_TRAVELTIME]
entity_ids = await async_extract_entity_ids(hass, service)
class FoscamCam(Camera): if not entity_ids:
"""An implementation of a Foscam IP camera.""" return
def __init__(self, device_info): _LOGGER.debug("Moving '%s' camera(s): %s", movement, entity_ids)
"""Initialize a Foscam camera."""
from libpyfoscam import FoscamCamera
super().__init__() all_cameras = hass.data[FOSCAM_DATA][FOSCAM_ENTITIES]
target_cameras = [
camera for camera in all_cameras if camera.entity_id in entity_ids
]
ip_address = device_info.get(CONF_IP) for camera in target_cameras:
port = device_info.get(CONF_PORT) await camera.async_perform_ptz(movement, travel_time)
self._username = device_info.get(CONF_USERNAME)
self._password = device_info.get(CONF_PASSWORD)
self._name = device_info.get(CONF_NAME)
self._motion_status = False
self._foscam_session = FoscamCamera( hass.services.async_register(
ip_address, port, self._username, self._password, verbose=False FOSCAM_DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA
) )
self._rtsp_port = device_info.get(CONF_RTSP_PORT) camera = FoscamCamera(
if not self._rtsp_port: config[CONF_IP],
result, response = self._foscam_session.get_port_info() config[CONF_PORT],
if result == 0: config[CONF_USERNAME],
self._rtsp_port = response.get("rtspPort") or response.get("mediaPort") config[CONF_PASSWORD],
verbose=False,
)
rtsp_port = config.get(CONF_RTSP_PORT)
if not rtsp_port:
ret, response = await hass.async_add_executor_job(camera.get_port_info)
if ret == 0:
rtsp_port = response.get("rtspPort") or response.get("mediaPort")
ret, response = await hass.async_add_executor_job(camera.get_motion_detect_config)
motion_status = False
if ret != 0 and response == 1:
motion_status = True
async_add_entities(
[
HassFoscamCamera(
camera,
config[CONF_NAME],
config[CONF_USERNAME],
config[CONF_PASSWORD],
rtsp_port,
motion_status,
)
]
)
class HassFoscamCamera(Camera):
"""An implementation of a Foscam IP camera."""
def __init__(self, camera, name, username, password, rtsp_port, motion_status):
"""Initialize a Foscam camera."""
super().__init__()
self._foscam_session = camera
self._name = name
self._username = username
self._password = password
self._rtsp_port = rtsp_port
self._motion_status = motion_status
async def async_added_to_hass(self):
"""Handle entity addition to hass."""
entities = self.hass.data.setdefault(FOSCAM_DATA, {}).setdefault(
FOSCAM_ENTITIES, []
)
entities.append(self)
def camera_image(self): def camera_image(self):
"""Return a still image response from the camera.""" """Return a still image response from the camera."""
# Send the request to snap a picture and return raw jpg data # Send the request to snap a picture and return raw jpg data
# Handle exception if host is not reachable or url failed # Handle exception if host is not reachable or url failed
result, response = self._foscam_session.snap_picture_2() result, response = self._foscam_session.snap_picture_2()
if result == FOSCAM_COMM_ERROR: if result != 0:
return None return None
return response return response
@ -97,19 +207,47 @@ class FoscamCam(Camera):
"""Enable motion detection in camera.""" """Enable motion detection in camera."""
try: try:
ret = self._foscam_session.enable_motion_detection() ret = self._foscam_session.enable_motion_detection()
self._motion_status = ret == FOSCAM_COMM_ERROR
if ret != 0:
return
self._motion_status = True
except TypeError: except TypeError:
_LOGGER.debug("Communication problem") _LOGGER.debug("Communication problem")
self._motion_status = False
def disable_motion_detection(self): def disable_motion_detection(self):
"""Disable motion detection.""" """Disable motion detection."""
try: try:
ret = self._foscam_session.disable_motion_detection() ret = self._foscam_session.disable_motion_detection()
self._motion_status = ret == FOSCAM_COMM_ERROR
if ret != 0:
return
self._motion_status = False
except TypeError: except TypeError:
_LOGGER.debug("Communication problem") _LOGGER.debug("Communication problem")
self._motion_status = False
async def async_perform_ptz(self, movement, travel_time):
"""Perform a PTZ action on the camera."""
_LOGGER.debug("PTZ action '%s' on %s", movement, self._name)
movement_function = getattr(self._foscam_session, MOVEMENT_ATTRS[movement])
ret, _ = await self.hass.async_add_executor_job(movement_function)
if ret != 0:
_LOGGER.error("Error moving %s '%s': %s", movement, self._name, ret)
return
await asyncio.sleep(travel_time)
ret, _ = await self.hass.async_add_executor_job(
self._foscam_session.ptz_stop_run
)
if ret != 0:
_LOGGER.error("Error stopping movement on '%s': %s", self._name, ret)
return
@property @property
def name(self): def name(self):

View File

@ -0,0 +1,5 @@
"""Constants for Foscam component."""
DOMAIN = "foscam"
DATA = "foscam"
ENTITIES = "entities"

View File

@ -6,5 +6,5 @@
"libpyfoscam==1.0" "libpyfoscam==1.0"
], ],
"dependencies": [], "dependencies": [],
"codeowners": [] "codeowners": ["@skgsergio"]
} }

View File

@ -0,0 +1,12 @@
ptz:
description: Pan/Tilt service for Foscam camera.
fields:
entity_id:
description: Name(s) of entities to move.
example: 'camera.living_room_camera'
movement:
description: "Direction of the movement. Allowed values: up, down, left, right, top_left, top_right, bottom_left, bottom_right."
example: 'up'
travel_time:
description: "(Optional) Travel time in seconds. Allowed values: float from 0 to 1. Default: 0.125"
example: 0.125