Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix battery level calculation for Nest Protect by adding support for battery voltage conversions #283

Merged
merged 5 commits into from
Dec 16, 2023
Merged
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
2 changes: 1 addition & 1 deletion config/configuration.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
default_config:

logger:
default: info
default: error
logs:
custom_components.nest_protect: debug

Expand Down
2 changes: 2 additions & 0 deletions custom_components/nest_protect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
client = NestClient(session=session, environment=NEST_ENVIRONMENTS[account_type])

try:
# Using user-retrieved cookies for authentication
if issue_token and cookies:
auth = await client.get_access_token_from_cookies(issue_token, cookies)
# Using refresh_token from legacy authentication method
elif refresh_token:
auth = await client.get_access_token_from_refresh_token(refresh_token)
else:
Expand Down
11 changes: 0 additions & 11 deletions custom_components/nest_protect/entity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
"""Entity class for Nest Protect."""
from __future__ import annotations

from enum import unique

from homeassistant.backports.enum import StrEnum
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
Expand Down Expand Up @@ -118,11 +115,3 @@ def __init__(
super().__init__(bucket, description, areas, client)
self._attr_name = f"{super().name} {self.entity_description.name}"
self._attr_unique_id = f"{super().unique_id}-{self.entity_description.key}"


# Used by state translations for sensor and select entities
@unique
class NestProtectDeviceClass(StrEnum):
"""Device class for Nest Protect specific devices."""

NIGHT_LIGHT_BRIGHTNESS = "nest_protect__night_light_brightness"
22 changes: 22 additions & 0 deletions custom_components/nest_protect/pynest/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Enums for Nest Protect."""
from enum import StrEnum, unique
import logging

_LOGGER = logging.getLogger(__name__)


@unique
class BucketType(StrEnum):
"""Bucket types."""

KRYPTONITE = "kryptonite"
TOPAZ = "topaz"
WHERE = "where"

UNKNOWN = "unknown"

@classmethod
def _missing_(cls, value): # type: ignore
_LOGGER.warning(f"Unsupported value {value} has been returned for {cls}")

return cls.UNKNOWN
7 changes: 7 additions & 0 deletions custom_components/nest_protect/pynest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import datetime
from typing import Any

from .enums import BucketType


@dataclass
class NestLimits:
Expand Down Expand Up @@ -72,6 +74,11 @@ class Bucket:
object_revision: str
object_timestamp: str
value: Any
type: str = ""

def __post_init__(self):
"""Set the expiry date during post init."""
self.type = BucketType(self.object_key.split(".")[0])


@dataclass
Expand Down
62 changes: 57 additions & 5 deletions custom_components/nest_protect/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.helpers.entity import EntityCategory
Expand All @@ -19,23 +18,74 @@
from . import HomeAssistantNestProtectData
from .const import DOMAIN
from .entity import NestDescriptiveEntity
from .pynest.enums import BucketType


def milli_volt_to_percentage(state: int):
"""
Convert battery level in mV to a percentage.

The battery life percentage in devices is estimated using slopes from the L91 battery's datasheet.
This is a rough estimation, and the battery life percentage is not linear.

Tests on various devices have shown accurate results.
"""
if 3000 < state <= 6000:
if 4950 < state <= 6000:
slope = 0.001816609
yint = -8.548096886
elif 4800 < state <= 4950:
slope = 0.000291667
yint = -0.991176471
elif 4500 < state <= 4800:
slope = 0.001077342
yint = -4.730392157
else:
slope = 0.000434641
yint = -1.825490196

return max(0, min(100, round(((slope * state) + yint) * 100)))

return None


@dataclass
class NestProtectSensorDescription(SensorEntityDescription):
"""Class to describe an Nest Protect sensor."""

value_fn: Callable[[Any], StateType] | None = None
bucket_type: BucketType | None = (
None # used to filter out sensors that are not supported by the device
)


SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
SENSOR_DESCRIPTIONS: list[NestProtectSensorDescription] = [
NestProtectSensorDescription(
key="battery_level",
name="Battery Level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
bucket_type=BucketType.KRYPTONITE,
),
# TODO Due to duplicate keys, this sensor is not available yet
# NestProtectSensorDescription(
# key="battery_level",
# name="Battery Voltage",
# value_fn=lambda state: round(state / 1000, 3),
# device_class=SensorDeviceClass.BATTERY,
# native_unit_of_measurement=UnitOfElectricPotential.VOLT,
# entity_category=EntityCategory.DIAGNOSTIC,
# bucket_type=BucketType.TOPAZ,
# ),
NestProtectSensorDescription(
key="battery_level",
name="Battery Level",
value_fn=lambda state: state if state <= 100 else None,
value_fn=lambda state: milli_volt_to_percentage(state),
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
bucket_type=BucketType.TOPAZ,
),
NestProtectSensorDescription(
name="Replace By",
Expand All @@ -50,7 +100,6 @@ class NestProtectSensorDescription(SensorEntityDescription):
value_fn=lambda state: round(state, 2),
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
# TODO Add Color Status (gray, green, yellow, red)
# TODO Smoke Status (OK, Warning, Emergency)
Expand All @@ -64,13 +113,16 @@ async def async_setup_entry(hass, entry, async_add_devices):
data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[NestProtectSensor] = []

SUPPORTED_KEYS = {
SUPPORTED_KEYS: dict[str, NestProtectSensorDescription] = {
description.key: description for description in SENSOR_DESCRIPTIONS
}

for device in data.devices.values():
for key in device.value:
if description := SUPPORTED_KEYS.get(key):
if description.bucket_type and device.type != description.bucket_type:
continue

entities.append(
NestProtectSensor(device, description, data.areas, data.client)
)
Expand Down
2 changes: 1 addition & 1 deletion requirements_dev.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
homeassistant==2023.12.0
homeassistant==2023.12.3
pre-commit
2 changes: 1 addition & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

pytest==7.4.3
pytest-socket==0.6.0
pytest-homeassistant-custom-component==0.13.82 # 2023.12.0
pytest-homeassistant-custom-component==0.13.85 # 2023.12.3
pytest-timeout==2.1.0