From 64c9aa0cff6c0f7db6effe131fddfdcbf9827338 Mon Sep 17 00:00:00 2001
From: Allen Porter <allen@thebends.org>
Date: Sun, 12 Nov 2023 12:49:49 -0800
Subject: [PATCH] Update Fitbit to avoid a KeyError when `restingHeartRate` is
 not present (#103872)

* Update Fitbit to avoid a KeyError when `restingHeartRate` is not present

* Explicitly handle none response values
---
 homeassistant/components/fitbit/sensor.py | 13 +++++-
 tests/components/fitbit/test_sensor.py    | 57 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 1 deletion(-)

diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
index 336a6620035..e2cfb3e3992 100644
--- a/homeassistant/components/fitbit/sensor.py
+++ b/homeassistant/components/fitbit/sensor.py
@@ -135,6 +135,17 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume:
     return UnitOfVolume.MILLILITERS
 
 
+def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]:
+    """Value function that will parse the specified field if present."""
+
+    def convert(result: dict[str, Any]) -> int | None:
+        if (value := result["value"].get(field)) is not None:
+            return int(value)
+        return None
+
+    return convert
+
+
 @dataclass
 class FitbitSensorEntityDescription(SensorEntityDescription):
     """Describes Fitbit sensor entity."""
@@ -207,7 +218,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
         name="Resting Heart Rate",
         native_unit_of_measurement="bpm",
         icon="mdi:heart-pulse",
-        value_fn=lambda result: int(result["value"]["restingHeartRate"]),
+        value_fn=_int_value_or_none("restingHeartRate"),
         scope=FitbitScope.HEART_RATE,
         state_class=SensorStateClass.MEASUREMENT,
     ),
diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py
index 08c9761bce2..871088eae63 100644
--- a/tests/components/fitbit/test_sensor.py
+++ b/tests/components/fitbit/test_sensor.py
@@ -808,3 +808,60 @@ async def test_device_battery_level_reauth_required(
     flows = hass.config_entries.flow.async_progress()
     assert len(flows) == 1
     assert flows[0]["step_id"] == "reauth_confirm"
+
+
+@pytest.mark.parametrize(
+    ("scopes", "response_data", "expected_state"),
+    [
+        (["heartrate"], {}, "unknown"),
+        (
+            ["heartrate"],
+            {
+                "restingHeartRate": 120,
+            },
+            "120",
+        ),
+        (
+            ["heartrate"],
+            {
+                "restingHeartRate": 0,
+            },
+            "0",
+        ),
+    ],
+    ids=("missing", "valid", "zero"),
+)
+async def test_resting_heart_rate_responses(
+    hass: HomeAssistant,
+    setup_credentials: None,
+    integration_setup: Callable[[], Awaitable[bool]],
+    register_timeseries: Callable[[str, dict[str, Any]], None],
+    response_data: dict[str, Any],
+    expected_state: str,
+) -> None:
+    """Test resting heart rate sensor with various values from response."""
+
+    register_timeseries(
+        "activities/heart",
+        timeseries_response(
+            "activities-heart",
+            {
+                "customHeartRateZones": [],
+                "heartRateZones": [
+                    {
+                        "caloriesOut": 0,
+                        "max": 220,
+                        "min": 159,
+                        "minutes": 0,
+                        "name": "Peak",
+                    },
+                ],
+                **response_data,
+            },
+        ),
+    )
+    assert await integration_setup()
+
+    state = hass.states.get("sensor.resting_heart_rate")
+    assert state
+    assert state.state == expected_state
-- 
GitLab