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
pull/27287/head
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/fortigate/*
homeassistant/components/foscam/camera.py
homeassistant/components/foscam/const.py
homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/*

View File

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

View File

@ -1,11 +1,26 @@
"""This component provides basic support for Foscam IP cameras."""
import logging
import asyncio
from libpyfoscam import FoscamCamera
import voluptuous as vol
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.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__)
@ -15,7 +30,32 @@ CONF_RTSP_PORT = "rtsp_port"
DEFAULT_NAME = "Foscam Camera"
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(
{
@ -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."""
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)
if not entity_ids:
return
_LOGGER.debug("Moving '%s' camera(s): %s", movement, entity_ids)
all_cameras = hass.data[FOSCAM_DATA][FOSCAM_ENTITIES]
target_cameras = [
camera for camera in all_cameras if camera.entity_id in entity_ids
]
for camera in target_cameras:
await camera.async_perform_ptz(movement, travel_time)
hass.services.async_register(
FOSCAM_DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA
)
camera = FoscamCamera(
config[CONF_IP],
config[CONF_PORT],
config[CONF_USERNAME],
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 FoscamCam(Camera):
class HassFoscamCamera(Camera):
"""An implementation of a Foscam IP camera."""
def __init__(self, device_info):
def __init__(self, camera, name, username, password, rtsp_port, motion_status):
"""Initialize a Foscam camera."""
from libpyfoscam import FoscamCamera
super().__init__()
ip_address = device_info.get(CONF_IP)
port = device_info.get(CONF_PORT)
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 = camera
self._name = name
self._username = username
self._password = password
self._rtsp_port = rtsp_port
self._motion_status = motion_status
self._foscam_session = FoscamCamera(
ip_address, port, self._username, self._password, verbose=False
async def async_added_to_hass(self):
"""Handle entity addition to hass."""
entities = self.hass.data.setdefault(FOSCAM_DATA, {}).setdefault(
FOSCAM_ENTITIES, []
)
self._rtsp_port = device_info.get(CONF_RTSP_PORT)
if not self._rtsp_port:
result, response = self._foscam_session.get_port_info()
if result == 0:
self._rtsp_port = response.get("rtspPort") or response.get("mediaPort")
entities.append(self)
def camera_image(self):
"""Return a still image response from the camera."""
# Send the request to snap a picture and return raw jpg data
# Handle exception if host is not reachable or url failed
result, response = self._foscam_session.snap_picture_2()
if result == FOSCAM_COMM_ERROR:
if result != 0:
return None
return response
@ -97,19 +207,47 @@ class FoscamCam(Camera):
"""Enable motion detection in camera."""
try:
ret = self._foscam_session.enable_motion_detection()
self._motion_status = ret == FOSCAM_COMM_ERROR
if ret != 0:
return
self._motion_status = True
except TypeError:
_LOGGER.debug("Communication problem")
self._motion_status = False
def disable_motion_detection(self):
"""Disable motion detection."""
try:
ret = self._foscam_session.disable_motion_detection()
self._motion_status = ret == FOSCAM_COMM_ERROR
if ret != 0:
return
self._motion_status = False
except TypeError:
_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
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"
],
"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