Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ target/
*.cache
credentials.json
*.code-workspace

# Coding agent stuff
.claude
27 changes: 17 additions & 10 deletions ring_doorbell/doorbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
50 changes: 50 additions & 0 deletions tests/fixtures/ring_devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
10 changes: 10 additions & 0 deletions tests/fixtures/ring_doorboot_health_attrs.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
35 changes: 35 additions & 0 deletions tests/fixtures/ring_dual_battery_health.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion tests/test_ring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]

Expand Down