Add ring switch platform (#25612)

* Add in a switch platform to ring.

* Changes following code review

* remove tests for now

* remove the request to call update

* support the new type of test

* update after running black

* fix comment

* fixes following code review

* Remove ring cache file

* patch out io code

* Move the patches to within a fixture

* missing period
pull/25732/head
Ross Dargan 2019-08-06 13:39:07 +01:00 committed by Martin Hjelmare
parent 7ff7c7b9f5
commit 9e8df936ac
7 changed files with 745 additions and 1 deletions

View File

@ -0,0 +1,108 @@
"""This component provides HA switch support for Ring Door Bell/Chimes."""
import logging
from datetime import datetime, timedelta
from homeassistant.components.switch import SwitchDevice
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import callback
from . import DATA_RING_STICKUP_CAMS, SIGNAL_UPDATE_RING
_LOGGER = logging.getLogger(__name__)
SIREN_ICON = "mdi:alarm-bell"
# It takes a few seconds for the API to correctly return an update indicating
# that the changes have been made. Once we request a change (i.e. a light
# being turned on) we simply wait for this time delta before we allow
# updates to take place.
SKIP_UPDATES_DELAY = timedelta(seconds=5)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Create the switches for the Ring devices."""
cameras = hass.data[DATA_RING_STICKUP_CAMS]
switches = []
for device in cameras:
if device.has_capability("siren"):
switches.append(SirenSwitch(device))
add_entities(switches, True)
class BaseRingSwitch(SwitchDevice):
"""Represents a switch for controlling an aspect of a ring device."""
def __init__(self, device, device_type):
"""Initialize the switch."""
self._device = device
self._device_type = device_type
self._unique_id = "{}-{}".format(self._device.id, self._device_type)
async def async_added_to_hass(self):
"""Register callbacks."""
async_dispatcher_connect(self.hass, SIGNAL_UPDATE_RING, self._update_callback)
@callback
def _update_callback(self):
"""Call update method."""
_LOGGER.debug("Updating Ring sensor %s (callback)", self.name)
self.async_schedule_update_ha_state(True)
@property
def name(self):
"""Name of the device."""
return "{} {}".format(self._device.name, self._device_type)
@property
def unique_id(self):
"""Return a unique ID."""
return self._unique_id
@property
def should_poll(self):
"""Update controlled via the hub."""
return False
class SirenSwitch(BaseRingSwitch):
"""Creates a switch to turn the ring cameras siren on and off."""
def __init__(self, device):
"""Initialize the switch for a device with a siren."""
super().__init__(device, "siren")
self._no_updates_until = datetime.now()
self._siren_on = False
def _set_switch(self, new_state):
"""Update switch state, and causes HASS to correctly update."""
self._device.siren = new_state
self._siren_on = new_state > 0
self._no_updates_until = datetime.now() + SKIP_UPDATES_DELAY
self.schedule_update_ha_state()
@property
def is_on(self):
"""If the switch is currently on or off."""
return self._siren_on
def turn_on(self, **kwargs):
"""Turn the siren on for 30 seconds."""
self._set_switch(1)
def turn_off(self, **kwargs):
"""Turn the siren off."""
self._set_switch(0)
@property
def icon(self):
"""Return the icon."""
return SIREN_ICON
def update(self):
"""Update state of the siren."""
if self._no_updates_until > datetime.now():
_LOGGER.debug("Skipping update...")
return
self._siren_on = self._device.siren > 0

View File

@ -0,0 +1,14 @@
"""Common methods used across the tests for ring devices."""
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL
from homeassistant.components.ring import DOMAIN
from homeassistant.setup import async_setup_component
async def setup_platform(hass, platform):
"""Set up the ring platform and prerequisites."""
config = {
DOMAIN: {CONF_USERNAME: "foo", CONF_PASSWORD: "bar", CONF_SCAN_INTERVAL: 1000},
platform: {"platform": DOMAIN},
}
assert await async_setup_component(hass, platform, config)
await hass.async_block_till_done()

View File

@ -0,0 +1,54 @@
"""Configuration for Ring tests."""
import requests_mock
import pytest
from tests.common import load_fixture
from asynctest import patch
@pytest.fixture(name="ring_mock")
def ring_save_mock():
"""Fixture to mock a ring."""
with patch("ring_doorbell._exists_cache", return_value=False):
with patch("ring_doorbell._save_cache", return_value=True) as save_mock:
yield save_mock
@pytest.fixture(name="requests_mock")
def requests_mock_fixture(ring_mock):
"""Fixture to provide a requests mocker."""
with requests_mock.mock() as mock:
# Note all devices have an id of 987652, but a different device_id.
# the device_id is used as our unique_id, but the id is what is sent
# to the APIs, which is why every mock uses that id.
# Mocks the response for authenticating
mock.post(
"https://oauth.ring.com/oauth/token", text=load_fixture("ring_oauth.json")
)
# Mocks the response for getting the login session
mock.post(
"https://api.ring.com/clients_api/session",
text=load_fixture("ring_session.json"),
)
# Mocks the response for getting all the devices
mock.get(
"https://api.ring.com/clients_api/ring_devices",
text=load_fixture("ring_devices.json"),
)
# Mocks the response for getting the history of a device
mock.get(
"https://api.ring.com/clients_api/doorbots/987652/history",
text=load_fixture("ring_doorbots.json"),
)
# Mocks the response for getting the health of a device
mock.get(
"https://api.ring.com/clients_api/doorbots/987652/health",
text=load_fixture("ring_doorboot_health_attrs.json"),
)
# Mocks the response for getting a chimes health
mock.get(
"https://api.ring.com/clients_api/chimes/999999/health",
text=load_fixture("ring_chime_health_attrs.json"),
)
yield mock

View File

@ -0,0 +1,75 @@
"""The tests for the Ring switch platform."""
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from tests.common import load_fixture
from .common import setup_platform
async def test_entity_registry(hass, requests_mock):
"""Tests that the devices are registed in the entity registry."""
await setup_platform(hass, SWITCH_DOMAIN)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
entry = entity_registry.async_get("switch.front_siren")
assert entry.unique_id == "aacdef123-siren"
entry = entity_registry.async_get("switch.internal_siren")
assert entry.unique_id == "aacdef124-siren"
async def test_siren_off_reports_correctly(hass, requests_mock):
"""Tests that the initial state of a device that should be off is correct."""
await setup_platform(hass, SWITCH_DOMAIN)
state = hass.states.get("switch.front_siren")
assert state.state == "off"
assert state.attributes.get("friendly_name") == "Front siren"
async def test_siren_on_reports_correctly(hass, requests_mock):
"""Tests that the initial state of a device that should be on is correct."""
await setup_platform(hass, SWITCH_DOMAIN)
state = hass.states.get("switch.internal_siren")
assert state.state == "on"
assert state.attributes.get("friendly_name") == "Internal siren"
assert state.attributes.get("icon") == "mdi:alarm-bell"
async def test_siren_can_be_turned_on(hass, requests_mock):
"""Tests the siren turns on correctly."""
await setup_platform(hass, SWITCH_DOMAIN)
# Mocks the response for turning a siren on
requests_mock.put(
"https://api.ring.com/clients_api/doorbots/987652/siren_on",
text=load_fixture("ring_doorbot_siren_on_response.json"),
)
state = hass.states.get("switch.front_siren")
assert state.state == "off"
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.front_siren"}, blocking=True
)
state = hass.states.get("switch.front_siren")
assert state.state == "on"
async def test_updates_work(hass, requests_mock):
"""Tests the update service works correctly."""
await setup_platform(hass, SWITCH_DOMAIN)
state = hass.states.get("switch.front_siren")
assert state.state == "off"
# Changes the return to indicate that the siren is now on.
requests_mock.get(
"https://api.ring.com/clients_api/ring_devices",
text=load_fixture("ring_devices_updated.json"),
)
await hass.services.async_call("ring", "update", {}, blocking=True)
await hass.async_block_till_done()
state = hass.states.get("switch.front_siren")
assert state.state == "on"

View File

@ -214,5 +214,142 @@
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"
}]
},
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"battery_life": 80,
"description": "Internal",
"device_id": "aacdef124",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"night_vision_enabled": false,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "1.9.3",
"id": 987652,
"kind": "hp_cam_v1",
"latitude": 12.000000,
"led_status": "off",
"location_id": null,
"longitude": -70.12345,
"motion_snooze": {"scheduled": true},
"night_mode_status": "false",
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Foo",
"id": 999999,
"last_name": "Bar"},
"ring_cam_light_installed": "false",
"ring_id": null,
"settings": {
"chime_settings": {
"duration": 10,
"enable": true,
"type": 0},
"doorbell_volume": 11,
"enable_vod": true,
"floodlight_settings": {
"duration": 30,
"priority": 0},
"light_schedule_settings": {
"end_hour": 0,
"end_minute": 0,
"start_hour": 0,
"start_minute": 0},
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"none",
"low",
"medium",
"high"],
"motion_zones": {
"active_motion_filter": 1,
"advanced_object_settings": {
"human_detection_confidence": {
"day": 0.7,
"night": 0.7},
"motion_zone_overlap": {
"day": 0.1,
"night": 0.2},
"object_size_maximum": {
"day": 0.8,
"night": 0.8},
"object_size_minimum": {
"day": 0.03,
"night": 0.05},
"object_time_overlap": {
"day": 0.1,
"night": 0.6}
},
"enable_audio": false,
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"sensitivity": 5,
"zone1": {
"name": "Zone 1",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone2": {
"name": "Zone 2",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone3": {
"name": "Zone 3",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}}},
"pir_motion_zones": [0, 1, 1],
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"stream_setting": 0,
"video_settings": {
"ae_level": 0,
"birton": null,
"brightness": 0,
"contrast": 64,
"saturation": 80}},
"siren_status": {"seconds_remaining": 30},
"stolen": false,
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}]
}

355
tests/fixtures/ring_devices_updated.json vendored Normal file
View File

@ -0,0 +1,355 @@
{
"authorized_doorbots": [],
"chimes": [
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"description": "Downstairs",
"device_id": "abcdef123",
"do_not_disturb": {"seconds_left": 0},
"features": {"ringtones_enabled": true},
"firmware_version": "1.2.3",
"id": 999999,
"kind": "chime",
"latitude": 12.000000,
"longitude": -70.12345,
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Marcelo",
"id": 999999,
"last_name": "Assistant"},
"settings": {
"ding_audio_id": null,
"ding_audio_user_id": null,
"motion_audio_id": null,
"motion_audio_user_id": null,
"volume": 2},
"time_zone": "America/New_York"}],
"doorbots": [
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"battery_life": 4081,
"description": "Front Door",
"device_id": "aacdef123",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "1.4.26",
"id": 987652,
"kind": "lpd_v1",
"latitude": 12.000000,
"longitude": -70.12345,
"motion_snooze": null,
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Home",
"id": 999999,
"last_name": "Assistant"},
"settings": {
"chime_settings": {
"duration": 3,
"enable": true,
"type": 0},
"doorbell_volume": 1,
"enable_vod": true,
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"null",
"low",
"medium",
"high"]},
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}],
"stickup_cams": [
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"battery_life": 80,
"description": "Front",
"device_id": "aacdef123",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"night_vision_enabled": false,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "1.9.3",
"id": 987652,
"kind": "hp_cam_v1",
"latitude": 12.000000,
"led_status": "off",
"location_id": null,
"longitude": -70.12345,
"motion_snooze": {"scheduled": true},
"night_mode_status": "false",
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Foo",
"id": 999999,
"last_name": "Bar"},
"ring_cam_light_installed": "false",
"ring_id": null,
"settings": {
"chime_settings": {
"duration": 10,
"enable": true,
"type": 0},
"doorbell_volume": 11,
"enable_vod": true,
"floodlight_settings": {
"duration": 30,
"priority": 0},
"light_schedule_settings": {
"end_hour": 0,
"end_minute": 0,
"start_hour": 0,
"start_minute": 0},
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"none",
"low",
"medium",
"high"],
"motion_zones": {
"active_motion_filter": 1,
"advanced_object_settings": {
"human_detection_confidence": {
"day": 0.7,
"night": 0.7},
"motion_zone_overlap": {
"day": 0.1,
"night": 0.2},
"object_size_maximum": {
"day": 0.8,
"night": 0.8},
"object_size_minimum": {
"day": 0.03,
"night": 0.05},
"object_time_overlap": {
"day": 0.1,
"night": 0.6}
},
"enable_audio": false,
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"sensitivity": 5,
"zone1": {
"name": "Zone 1",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone2": {
"name": "Zone 2",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone3": {
"name": "Zone 3",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}}},
"pir_motion_zones": [0, 1, 1],
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"stream_setting": 0,
"video_settings": {
"ae_level": 0,
"birton": null,
"brightness": 0,
"contrast": 64,
"saturation": 80}},
"siren_status": {"seconds_remaining": 30},
"stolen": false,
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"
},
{
"address": "123 Main St",
"alerts": {"connection": "online"},
"battery_life": 80,
"description": "Internal",
"device_id": "aacdef124",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"night_vision_enabled": false,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "1.9.3",
"id": 987652,
"kind": "hp_cam_v1",
"latitude": 12.000000,
"led_status": "off",
"location_id": null,
"longitude": -70.12345,
"motion_snooze": {"scheduled": true},
"night_mode_status": "false",
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Foo",
"id": 999999,
"last_name": "Bar"},
"ring_cam_light_installed": "false",
"ring_id": null,
"settings": {
"chime_settings": {
"duration": 10,
"enable": true,
"type": 0},
"doorbell_volume": 11,
"enable_vod": true,
"floodlight_settings": {
"duration": 30,
"priority": 0},
"light_schedule_settings": {
"end_hour": 0,
"end_minute": 0,
"start_hour": 0,
"start_minute": 0},
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"none",
"low",
"medium",
"high"],
"motion_zones": {
"active_motion_filter": 1,
"advanced_object_settings": {
"human_detection_confidence": {
"day": 0.7,
"night": 0.7},
"motion_zone_overlap": {
"day": 0.1,
"night": 0.2},
"object_size_maximum": {
"day": 0.8,
"night": 0.8},
"object_size_minimum": {
"day": 0.03,
"night": 0.05},
"object_time_overlap": {
"day": 0.1,
"night": 0.6}
},
"enable_audio": false,
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"sensitivity": 5,
"zone1": {
"name": "Zone 1",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone2": {
"name": "Zone 2",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}},
"zone3": {
"name": "Zone 3",
"state": 2,
"vertex1": {"x": 0.0, "y": 0.0},
"vertex2": {"x": 0.0, "y": 0.0},
"vertex3": {"x": 0.0, "y": 0.0},
"vertex4": {"x": 0.0, "y": 0.0},
"vertex5": {"x": 0.0, "y": 0.0},
"vertex6": {"x": 0.0, "y": 0.0},
"vertex7": {"x": 0.0, "y": 0.0},
"vertex8": {"x": 0.0, "y": 0.0}}},
"pir_motion_zones": [0, 1, 1],
"pir_settings": {
"sensitivity1": 1,
"sensitivity2": 1,
"sensitivity3": 1,
"zone_mask": 6},
"stream_setting": 0,
"video_settings": {
"ae_level": 0,
"birton": null,
"brightness": 0,
"contrast": 64,
"saturation": 80}},
"siren_status": {"seconds_remaining": 30},
"stolen": false,
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}]
}

View File

@ -0,0 +1 @@
{"started_at":"2019-07-28T16:58:27.593+00:00","duration":30,"ends_at":"2019-07-28T16:58:57.593+00:00","seconds_remaining":30}