diff --git a/.coveragerc b/.coveragerc index 6f3dfbc94a8..8253b5522f7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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/* diff --git a/CODEOWNERS b/CODEOWNERS index ba4058d5acf..d550f3e6924 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 63e9956d0df..0e2ca4073bf 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -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): diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py new file mode 100644 index 00000000000..63b4b74a763 --- /dev/null +++ b/homeassistant/components/foscam/const.py @@ -0,0 +1,5 @@ +"""Constants for Foscam component.""" + +DOMAIN = "foscam" +DATA = "foscam" +ENTITIES = "entities" diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index b2c44c113ee..6a47012ef84 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "libpyfoscam==1.0" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@skgsergio"] } diff --git a/homeassistant/components/foscam/services.yaml b/homeassistant/components/foscam/services.yaml new file mode 100644 index 00000000000..64e68dd5bc4 --- /dev/null +++ b/homeassistant/components/foscam/services.yaml @@ -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