diff --git a/.gitignore b/.gitignore index 069c3b7..39c5344 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ target/ *.cache credentials.json *.code-workspace + +# Coding agent stuff +.claude diff --git a/ring_doorbell/doorbot.py b/ring_doorbell/doorbot.py index 7fa6809..f1d6bac 100644 --- a/ring_doorbell/doorbot.py +++ b/ring_doorbell/doorbot.py @@ -158,19 +158,26 @@ def has_capability(self, capability: RingCapability | str) -> bool: # noqa: PLR @property def battery_life(self) -> int | None: """Return battery life.""" - if ( - bl1 := self._attrs.get("battery_life") - ) is None and "battery_life_2" not in self._attrs: + if (active := self._health_attrs.get("active_battery")) is not None and ( + batteries := self._health_attrs.get("batteries") + ): + for bat in batteries: + if bat["battery_number"] == active: + return bat["battery_percentage"] + bl = self._attrs.get("battery_life") + if bl is None: return None + return int(bl) - value = 0 - if bl1: - value += int(bl1) - - if bl2 := self._attrs.get("battery_life_2"): # Camera has two battery bays - value += int(bl2) + @property + def batteries(self) -> list[dict] | None: + """Return per-battery data from the health payload, or None if unavailable.""" + return self._health_attrs.get("batteries") or None - return min(value, 100) + @property + def active_battery(self) -> int | None: + """Return the index of the battery bay currently powering the device.""" + return self._health_attrs.get("active_battery") def _get_chime_setting(self, setting: str) -> Any | None: if (settings := self._attrs.get("settings")) and ( diff --git a/tests/conftest.py b/tests/conftest.py index 0283a2a..3501d9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -222,6 +222,11 @@ def aioresponses_mock_fixture(request, devices_fixture, putpatch_status_fixture) payload=load_fixture_as_dict("ring_chime_health_attrs.json"), repeat=True, ) + mock.get( + "https://api.ring.com/clients_api/doorbots/987655/health", + payload=load_fixture_as_dict("ring_dual_battery_health.json"), + repeat=True, + ) mock.get( re.compile(r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/health"), payload=load_fixture_as_dict("ring_doorboot_health_attrs.json"), diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json index 84233fb..e263c6e 100644 --- a/tests/fixtures/ring_devices.json +++ b/tests/fixtures/ring_devices.json @@ -265,6 +265,56 @@ "stolen": false, "subscribed": true, "subscribed_motions": true, + "time_zone": "America/New_York" }, + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "battery_life": 12, + "battery_life_2": 97, + "description": "Back Yard", + "device_id": "aacdef456", + "external_connection": false, + "features": { + "advanced_motion_enabled": false, + "motion_message_enabled": false, + "motions_enabled": true, + "night_vision_enabled": true, + "people_only_enabled": false, + "shadow_correction_enabled": false, + "show_recordings": true}, + "firmware_version": "2.1.0", + "id": 987655, + "kind": "stickup_cam_v4", + "latitude": 12.000000, + "led_status": "off", + "location_id": "mock-location-id", + "longitude": -70.12345, + "motion_snooze": {"scheduled": false}, + "night_mode_status": "false", + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Foo", + "id": 999999, + "last_name": "Bar"}, + "settings": { + "chime_settings": { + "duration": 10, + "enable": true, + "type": 0}, + "doorbell_volume": 11, + "enable_vod": true, + "live_view_preset_profile": "highest", + "live_view_presets": ["low", "middle", "high", "highest"], + "motion_announcement": false, + "motion_detection_enabled": false, + "motion_snooze_preset_profile": "low", + "motion_snooze_presets": ["none", "low", "medium", "high"], + "stream_setting": 0}, + "siren_status": {"seconds_remaining": 0}, + "stolen": false, + "subscribed": true, + "subscribed_motions": true, "time_zone": "America/New_York" }], "other": [ { diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/fixtures/ring_doorboot_health_attrs.json index f84678d..a0cd743 100644 --- a/tests/fixtures/ring_doorboot_health_attrs.json +++ b/tests/fixtures/ring_doorboot_health_attrs.json @@ -1,7 +1,17 @@ { "device_health": { + "active_battery": 1, "average_signal_category": "good", "average_signal_strength": -39, + "batteries": [ + { + "battery_number": 1, + "battery_percentage": 100, + "battery_percentage_category": "very_good", + "battery_voltage": 4144.0, + "battery_voltage_category": "very_good" + } + ], "battery_percentage": 100, "battery_percentage_category": null, "battery_voltage": null, diff --git a/tests/fixtures/ring_dual_battery_health.json b/tests/fixtures/ring_dual_battery_health.json new file mode 100644 index 0000000..e1b7d15 --- /dev/null +++ b/tests/fixtures/ring_dual_battery_health.json @@ -0,0 +1,35 @@ +{ + "device_health": { + "active_battery": 2, + "average_signal_category": "good", + "average_signal_strength": -45, + "batteries": [ + { + "battery_number": 1, + "battery_percentage": 12, + "battery_percentage_category": "very_poor", + "battery_voltage": 3562.0, + "battery_voltage_category": "good" + }, + { + "battery_number": 2, + "battery_percentage": 97, + "battery_percentage_category": "very_good", + "battery_voltage": 4144.0, + "battery_voltage_category": "very_good" + } + ], + "battery_percentage": 12, + "battery_percentage_category": "very_poor", + "battery_voltage": 3562.0, + "battery_voltage_category": "good", + "firmware": "2.1.0", + "firmware_out_of_date": false, + "id": 987655, + "latest_signal_category": "good", + "latest_signal_strength": -52, + "updated_at": "2024-01-15T10:00:00Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 8401223..0914023 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -91,7 +91,7 @@ async def test_list(ring): expected = ( "Front Door (lpd_v1)\nBack Door (lpd_v1)\nDownstairs (chime)\n" - "Front (hp_cam_v1)\nIngress (intercom_handset_audio)\n" + "Front (hp_cam_v1)\nBack Yard (stickup_cam_v4)\nIngress (intercom_handset_audio)\n" ) assert res.exit_code == 0 diff --git a/tests/test_ring.py b/tests/test_ring.py index def8f47..3e66eef 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -18,7 +18,7 @@ def test_basic_attributes(ring): assert len(data["chimes"]) == 1 assert len(data["doorbots"]) == 1 assert len(data["authorized_doorbots"]) == 1 - assert len(data["stickup_cams"]) == 1 + assert len(data["stickup_cams"]) == 2 assert len(data["other"]) == 1 @@ -113,6 +113,49 @@ def test_stickup_cam_attributes(ring): assert dev.siren == 0 +async def test_single_battery_properties(ring): + dev = ring.devices()["stickup_cams"][0] + assert dev.battery_life == 100 + assert dev.batteries is None + assert dev.active_battery is None + + await dev.async_update_health_data() + + assert dev.batteries is not None + assert len(dev.batteries) == 1 + assert dev.batteries[0]["battery_number"] == 1 + assert dev.batteries[0]["battery_percentage"] == 100 + assert dev.batteries[0]["battery_voltage"] == 4144.0 + assert dev.active_battery == 1 + + +async def test_dual_battery_properties(ring): + devs = ring.devices()["stickup_cams"] + dev = next(d for d in devs if d.id == 987655) + + assert dev.kind == "stickup_cam_v4" + assert dev.has_capability("battery") is True + assert dev.battery_life == 12 + + await dev.async_update_health_data() + + assert dev.batteries is not None + assert len(dev.batteries) == 2 + + bat1, bat2 = dev.batteries + assert bat1["battery_number"] == 1 + assert bat1["battery_percentage"] == 12 + assert bat1["battery_percentage_category"] == "very_poor" + assert bat1["battery_voltage"] == 3562.0 + + assert bat2["battery_number"] == 2 + assert bat2["battery_percentage"] == 97 + assert bat2["battery_percentage_category"] == "very_good" + + assert dev.active_battery == 2 + assert dev.battery_life == 97 + + async def test_stickup_cam_controls(ring, aioresponses_mock): dev = ring.devices()["stickup_cams"][0]