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 codeownerspull/27287/head
parent
4124211095
commit
f6b8cffeaf
|
@ -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/*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
"""Constants for Foscam component."""
|
||||
|
||||
DOMAIN = "foscam"
|
||||
DATA = "foscam"
|
||||
ENTITIES = "entities"
|
|
@ -6,5 +6,5 @@
|
|||
"libpyfoscam==1.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
"codeowners": ["@skgsergio"]
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue