Add Ring Intercom support (#109819)

* Add button entity

* Add support for Ring intercom ("other" device type)

* description

* format

* - Tests
- Fallback when intercom devices arent inside response

* Fix ring button

* Update library

* Fix button after merge

* Move names to strings.json

* Remove button entity_category

* Add wifi sensors to other

* Add last_ sensors to other

* Fix tests

* Add button test

* Add new sensors tests

* Revert "Add last_ sensors to other"

This reverts commit 5c03bba5a10130489bb33897a1952ca426bd725a.

* Update library

* Revert "Revert "Add last_ sensors to other""

This reverts commit 27631978d0d940a3fcbece761357d97c02bcca55.

* Fix tests

* Remove default list for other

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>

* Copy mock to conftest

* Fix history test

* Change time skip

* Remove button

* Fix history test

---------

Co-authored-by: Martin Pham <tuyentq2009@gmail.com>
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
This commit is contained in:
cosimomeli 2024-03-15 12:59:36 +01:00 committed by GitHub
parent 30d1f70468
commit 360f7dea75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 412 additions and 15 deletions

View File

@ -31,7 +31,7 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
RingBinarySensorEntityDescription(
key="ding",
translation_key="ding",
category=["doorbots", "authorized_doorbots"],
category=["doorbots", "authorized_doorbots", "other"],
device_class=BinarySensorDeviceClass.OCCUPANCY,
),
RingBinarySensorEntityDescription(
@ -56,7 +56,7 @@ async def async_setup_entry(
entities = [
RingBinarySensor(ring, device, notifications_coordinator, description)
for device_type in ("doorbots", "authorized_doorbots", "stickup_cams")
for device_type in ("doorbots", "authorized_doorbots", "stickup_cams", "other")
for description in BINARY_SENSOR_TYPES
if device_type in description.category
for device in devices[device_type]

View File

@ -13,6 +13,15 @@
"volume": {
"default": "mdi:bell-ring"
},
"doorbell_volume": {
"default": "mdi:bell-ring"
},
"mic_volume": {
"default": "mdi:microphone"
},
"voice_volume": {
"default": "mdi:account-voice"
},
"wifi_signal_category": {
"default": "mdi:wifi"
},

View File

@ -40,7 +40,13 @@ async def async_setup_entry(
entities = [
description.cls(device, devices_coordinator, description)
for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams")
for device_type in (
"chimes",
"doorbots",
"authorized_doorbots",
"stickup_cams",
"other",
)
for description in SENSOR_TYPES
if device_type in description.category
for device in devices[device_type]
@ -72,6 +78,12 @@ class RingSensor(RingEntity, SensorEntity):
sensor_type = self.entity_description.key
if sensor_type == "volume":
return self._device.volume
if sensor_type == "doorbell_volume":
return self._device.doorbell_volume
if sensor_type == "mic_volume":
return self._device.mic_volume
if sensor_type == "voice_volume":
return self._device.voice_volume
if sensor_type == "battery":
return self._device.battery_life
@ -156,7 +168,7 @@ class RingSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = (
RingSensorEntityDescription(
key="battery",
category=["doorbots", "authorized_doorbots", "stickup_cams"],
category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
@ -166,14 +178,14 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = (
RingSensorEntityDescription(
key="last_activity",
translation_key="last_activity",
category=["doorbots", "authorized_doorbots", "stickup_cams"],
category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
device_class=SensorDeviceClass.TIMESTAMP,
cls=HistoryRingSensor,
),
RingSensorEntityDescription(
key="last_ding",
translation_key="last_ding",
category=["doorbots", "authorized_doorbots"],
category=["doorbots", "authorized_doorbots", "other"],
kind="ding",
device_class=SensorDeviceClass.TIMESTAMP,
cls=HistoryRingSensor,
@ -192,17 +204,35 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = (
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"],
cls=RingSensor,
),
RingSensorEntityDescription(
key="doorbell_volume",
translation_key="doorbell_volume",
category=["other"],
cls=RingSensor,
),
RingSensorEntityDescription(
key="mic_volume",
translation_key="mic_volume",
category=["other"],
cls=RingSensor,
),
RingSensorEntityDescription(
key="voice_volume",
translation_key="voice_volume",
category=["other"],
cls=RingSensor,
),
RingSensorEntityDescription(
key="wifi_signal_category",
translation_key="wifi_signal_category",
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"],
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
entity_category=EntityCategory.DIAGNOSTIC,
cls=HealthDataRingSensor,
),
RingSensorEntityDescription(
key="wifi_signal_strength",
translation_key="wifi_signal_strength",
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"],
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,

View File

@ -60,6 +60,15 @@
"volume": {
"name": "Volume"
},
"doorbell_volume": {
"name": "Doorbell volume"
},
"mic_volume": {
"name": "Mic volume"
},
"voice_volume": {
"name": "Voice volume"
},
"wifi_signal_category": {
"name": "Wi-Fi signal category"
},

View File

@ -96,7 +96,7 @@ def requests_mock_fixture():
re.compile(
r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/history"
),
text=load_fixture("doorbots.json", "ring"),
text=load_fixture("doorbot_history.json", "ring"),
)
# Mocks the response for getting the health of a device
mock.get(
@ -115,6 +115,15 @@ def requests_mock_fixture():
status_code=200,
json={"url": "http://127.0.0.1/foo"},
)
mock.get(
"https://api.ring.com/groups/v1/locations/mock-location-id/groups",
text=load_fixture("groups.json", "ring"),
)
# Mocks the response for getting the history of the intercom
mock.get(
"https://api.ring.com/clients_api/doorbots/185036587/history",
text=load_fixture("intercom_history.json", "ring"),
)
# Mocks the response for setting properties in settings (i.e. motion_detection)
mock.patch(
re.compile(

View File

@ -378,5 +378,92 @@
"subscribed_motions": true,
"time_zone": "America/New_York"
}
],
"other": [
{
"id": 185036587,
"kind": "intercom_handset_audio",
"description": "Ingress",
"location_id": "mock-location-id",
"schema_id": null,
"is_sidewalk_gateway": false,
"created_at": "2023-12-01T18:05:25Z",
"deactivated_at": null,
"owner": {
"id": 762490876,
"first_name": "",
"last_name": "",
"email": ""
},
"device_id": "124ba1b3fe1a",
"time_zone": "Europe/Rome",
"firmware_version": "Up to Date",
"owned": true,
"ring_net_id": null,
"settings": {
"features_confirmed": 5,
"show_recordings": true,
"recording_ttl": 180,
"recording_enabled": false,
"keep_alive": null,
"keep_alive_auto": 45.0,
"doorbell_volume": 8,
"enable_chime": 1,
"theft_alarm_enable": 0,
"use_cached_domain": 1,
"use_server_ip": 0,
"server_domain": "fw.ring.com",
"server_ip": null,
"enable_log": 1,
"forced_keep_alive": null,
"mic_volume": 11,
"chime_settings": {
"enable": true,
"type": 2,
"duration": 10
},
"intercom_settings": {
"ring_to_open": false,
"predecessor": "{\"make\":\"Comelit\",\"model\":\"2738W\",\"wires\":2}",
"config": "{\"intercom_type\": 2, \"number_of_wires\": 2, \"autounlock_enabled\": false, \"speaker_gain\": [-49, -35, -25, -21, -16, -9, -6, -3, 0, 3, 6, 9], \"digital\": {\"audio_amp\": 0, \"chg_en\": false, \"fast_chg\": false, \"bypass\": false, \"idle_lvl\": 32, \"ext_audio\": false, \"ext_audio_term\": 0, \"off_hk_tm\": 0, \"unlk_ka\": false, \"unlock\": {\"cap_tm\": 1000, \"rpl_tm\": 1500, \"gain\": 1000, \"cmp_thr\": 4000, \"lvl\": 25000, \"thr\": 30, \"thr2\": 0, \"offln\": false, \"ac\": true, \"bias\": \"h\", \"tx_pin\": \"TXD2\", \"ack\": 0, \"prot\": \"Comelit_SB2\", \"ask\": {\"f\": 25000, \"b\": 333}}, \"ring\": {\"cap_tm\": 40, \"rpl_tm\": 200, \"gain\": 2000, \"cmp_thr\": 4500, \"lvl\": 28000, \"thr\": 30, \"thr2\": 0, \"offln\": false, \"ac\": true, \"bias\": \"m\", \"tx_pin\": \"TXD2\", \"ack\": 0, \"prot\": \"Comelit_SB2\", \"ask\": {\"f\": 25000, \"b\": 333}}, \"hook_off\": {\"cap_tm\": 1000, \"rpl_tm\": 1500, \"gain\": 1000, \"cmp_thr\": 4000, \"lvl\": 25000, \"thr\": 30, \"thr2\": 0, \"offln\": false, \"ac\": true, \"bias\": \"h\", \"tx_pin\": \"TXD2\", \"ack\": 0, \"prot\": \"Comelit_SB2\", \"ask\": {\"f\": 25000, \"b\": 333}}, \"hook_on\": {\"cap_tm\": 1000, \"rpl_tm\": 1500, \"gain\": 1000, \"cmp_thr\": 4000, \"lvl\": 25000, \"thr\": 30, \"thr2\": 0, \"offln\": false, \"ac\": true, \"bias\": \"h\", \"tx_pin\": \"TXD2\", \"ack\": 0, \"prot\": \"Comelit_SB2\", \"ask\": {\"f\": 25000, \"b\": 333}}}}",
"intercom_type": "DF",
"replication": 1,
"unlock_mode": 0
},
"voice_volume": 11
},
"alerts": {
"connection": "online",
"ota_status": "timeout"
},
"function": {
"name": null
},
"subscribed": false,
"battery_life": "52",
"features": {
"cfes_eligible": false,
"motion_zone_recommendation": false,
"motions_enabled": true,
"show_recordings": true,
"show_vod_settings": true,
"rich_notifications_eligible": false,
"show_offline_motion_events": false,
"sheila_camera_eligible": null,
"sheila_camera_processing_eligible": null,
"dynamic_network_switching_eligible": false,
"chime_auto_detect_capable": false,
"missing_key_delivery_address": false,
"show_24x7_lite": false,
"recording_24x7_eligible": null
},
"metadata": {
"ethernet": false,
"legacy_fw_migrated": true,
"imported_from_amazon": false,
"is_sidewalk_gateway": false,
"key_access_point_associated": true
}
}
]
}

View File

@ -0,0 +1,24 @@
{
"device_groups": [
{
"device_group_id": "mock-group-id",
"location_id": "mock-location-id",
"name": "Landscape",
"devices": [
{
"doorbot_id": 12345678,
"location_id": "mock-location-id",
"type": "beams_ct200_transformer",
"mac_address": null,
"hardware_id": "1234567890",
"name": "Mock Transformer",
"deleted_at": null
}
],
"created_at": "2020-11-03T22:07:05Z",
"updated_at": "2020-11-19T03:52:59Z",
"deleted_at": null,
"external_id": "12345678-1234-5678-90ab-1234567890ab"
}
]
}

View File

@ -0,0 +1,116 @@
[
{
"id": 7330963245622279024,
"created_at": "2024-02-02T11:21:24.000Z",
"answered": false,
"events": [],
"kind": "ding",
"favorite": false,
"snapshot_url": "",
"recording": {
"status": "ready"
},
"duration": 40.0,
"cv_properties": {
"person_detected": null,
"stream_broken": false,
"detection_type": null,
"cv_triggers": null,
"detection_types": null,
"security_alerts": null
},
"properties": {
"is_alexa": false,
"is_sidewalk": false,
"is_autoreply": false
},
"doorbot": {
"id": 185036587,
"description": "Ingresso",
"type": "intercom_handset_audio"
},
"device_placement": null,
"geolocation": null,
"last_location": null,
"siren": null,
"is_e2ee": false,
"had_subscription": false,
"owner_id": "762490876"
},
{
"id": 7323267080901445808,
"created_at": "2024-01-12T17:36:28.000Z",
"answered": true,
"events": [],
"kind": "on_demand",
"favorite": false,
"snapshot_url": "",
"recording": {
"status": "ready"
},
"duration": 13.0,
"cv_properties": {
"person_detected": null,
"stream_broken": false,
"detection_type": null,
"cv_triggers": null,
"detection_types": null,
"security_alerts": null
},
"properties": {
"is_alexa": false,
"is_sidewalk": false,
"is_autoreply": false
},
"doorbot": {
"id": 185036587,
"description": "Ingresso",
"type": "intercom_handset_audio"
},
"device_placement": null,
"geolocation": null,
"last_location": null,
"siren": null,
"is_e2ee": false,
"had_subscription": false,
"owner_id": "762490876"
},
{
"id": 7307399027047288688,
"created_at": "2023-12-01T18:44:28.000Z",
"answered": true,
"events": [],
"kind": "on_demand",
"favorite": false,
"snapshot_url": "",
"recording": {
"status": "ready"
},
"duration": 43.0,
"cv_properties": {
"person_detected": null,
"stream_broken": false,
"detection_type": null,
"cv_triggers": null,
"detection_types": null,
"security_alerts": null
},
"properties": {
"is_alexa": false,
"is_sidewalk": false,
"is_autoreply": false
},
"doorbot": {
"id": 185036587,
"description": "Ingresso",
"type": "intercom_handset_audio"
},
"device_placement": null,
"geolocation": null,
"last_location": null,
"siren": null,
"is_e2ee": false,
"had_subscription": false,
"owner_id": "762490876"
}
]

View File

@ -577,6 +577,91 @@
'subscribed_motions': True,
'time_zone': 'America/New_York',
}),
dict({
'alerts': dict({
'connection': 'online',
'ota_status': 'timeout',
}),
'battery_life': '52',
'created_at': '2023-12-01T18:05:25Z',
'deactivated_at': None,
'description': '**REDACTED**',
'device_id': '**REDACTED**',
'features': dict({
'cfes_eligible': False,
'chime_auto_detect_capable': False,
'dynamic_network_switching_eligible': False,
'missing_key_delivery_address': False,
'motion_zone_recommendation': False,
'motions_enabled': True,
'recording_24x7_eligible': None,
'rich_notifications_eligible': False,
'sheila_camera_eligible': None,
'sheila_camera_processing_eligible': None,
'show_24x7_lite': False,
'show_offline_motion_events': False,
'show_recordings': True,
'show_vod_settings': True,
}),
'firmware_version': 'Up to Date',
'function': dict({
'name': None,
}),
'id': '**REDACTED**',
'is_sidewalk_gateway': False,
'kind': 'intercom_handset_audio',
'location_id': '**REDACTED**',
'metadata': dict({
'ethernet': False,
'imported_from_amazon': False,
'is_sidewalk_gateway': False,
'key_access_point_associated': True,
'legacy_fw_migrated': True,
}),
'owned': True,
'owner': dict({
'email': '',
'first_name': '',
'id': '**REDACTED**',
'last_name': '',
}),
'ring_net_id': None,
'schema_id': None,
'settings': dict({
'chime_settings': dict({
'duration': 10,
'enable': True,
'type': 2,
}),
'doorbell_volume': 8,
'enable_chime': 1,
'enable_log': 1,
'features_confirmed': 5,
'forced_keep_alive': None,
'intercom_settings': dict({
'config': '{"intercom_type": 2, "number_of_wires": 2, "autounlock_enabled": false, "speaker_gain": [-49, -35, -25, -21, -16, -9, -6, -3, 0, 3, 6, 9], "digital": {"audio_amp": 0, "chg_en": false, "fast_chg": false, "bypass": false, "idle_lvl": 32, "ext_audio": false, "ext_audio_term": 0, "off_hk_tm": 0, "unlk_ka": false, "unlock": {"cap_tm": 1000, "rpl_tm": 1500, "gain": 1000, "cmp_thr": 4000, "lvl": 25000, "thr": 30, "thr2": 0, "offln": false, "ac": true, "bias": "h", "tx_pin": "TXD2", "ack": 0, "prot": "Comelit_SB2", "ask": {"f": 25000, "b": 333}}, "ring": {"cap_tm": 40, "rpl_tm": 200, "gain": 2000, "cmp_thr": 4500, "lvl": 28000, "thr": 30, "thr2": 0, "offln": false, "ac": true, "bias": "m", "tx_pin": "TXD2", "ack": 0, "prot": "Comelit_SB2", "ask": {"f": 25000, "b": 333}}, "hook_off": {"cap_tm": 1000, "rpl_tm": 1500, "gain": 1000, "cmp_thr": 4000, "lvl": 25000, "thr": 30, "thr2": 0, "offln": false, "ac": true, "bias": "h", "tx_pin": "TXD2", "ack": 0, "prot": "Comelit_SB2", "ask": {"f": 25000, "b": 333}}, "hook_on": {"cap_tm": 1000, "rpl_tm": 1500, "gain": 1000, "cmp_thr": 4000, "lvl": 25000, "thr": 30, "thr2": 0, "offln": false, "ac": true, "bias": "h", "tx_pin": "TXD2", "ack": 0, "prot": "Comelit_SB2", "ask": {"f": 25000, "b": 333}}}}',
'intercom_type': 'DF',
'predecessor': '{"make":"Comelit","model":"2738W","wires":2}',
'replication': 1,
'ring_to_open': False,
'unlock_mode': 0,
}),
'keep_alive': None,
'keep_alive_auto': 45.0,
'mic_volume': 11,
'recording_enabled': False,
'recording_ttl': 180,
'server_domain': 'fw.ring.com',
'server_ip': None,
'show_recordings': True,
'theft_alarm_enable': 0,
'use_cached_domain': 1,
'use_server_ip': 0,
'voice_volume': 11,
}),
'subscribed': False,
'time_zone': 'Europe/Rome',
}),
]),
})
# ---

View File

@ -33,6 +33,10 @@ async def test_binary_sensor(
assert motion_state.state == "on"
assert motion_state.attributes["device_class"] == "motion"
ding_state = hass.states.get("binary_sensor.front_door_ding")
assert ding_state is not None
assert ding_state.state == "off"
front_ding_state = hass.states.get("binary_sensor.front_door_ding")
assert front_ding_state is not None
assert front_ding_state.state == "off"
ingress_ding_state = hass.states.get("binary_sensor.ingress_ding")
assert ingress_ding_state is not None
assert ingress_ding_state.state == "off"

View File

@ -40,13 +40,19 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker)
assert downstairs_volume_state is not None
assert downstairs_volume_state.state == "2"
front_door_last_activity_state = hass.states.get("sensor.front_door_last_activity")
assert front_door_last_activity_state is not None
downstairs_wifi_signal_strength_state = hass.states.get(
"sensor.downstairs_wifi_signal_strength"
)
ingress_mic_volume_state = hass.states.get("sensor.ingress_mic_volume")
assert ingress_mic_volume_state.state == "11"
ingress_doorbell_volume_state = hass.states.get("sensor.ingress_doorbell_volume")
assert ingress_doorbell_volume_state.state == "8"
ingress_voice_volume_state = hass.states.get("sensor.ingress_voice_volume")
assert ingress_voice_volume_state.state == "11"
if not WIFI_ENABLED:
return
@ -66,6 +72,24 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker)
assert front_door_wifi_signal_strength_state.state == "-58"
async def test_history(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
requests_mock: requests_mock.Mocker,
) -> None:
"""Test history derived sensors."""
await setup_platform(hass, Platform.SENSOR)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(True)
front_door_last_activity_state = hass.states.get("sensor.front_door_last_activity")
assert front_door_last_activity_state.state == "2017-03-05T15:03:40+00:00"
ingress_last_activity_state = hass.states.get("sensor.ingress_last_activity")
assert ingress_last_activity_state.state == "unknown"
async def test_only_chime_devices(
hass: HomeAssistant,
requests_mock: requests_mock.Mocker,