From b21cd930369068e5457c34715531a2bd66e6400b Mon Sep 17 00:00:00 2001 From: Kuba Sawulski <13100091+kubasaw@users.noreply.github.com> Date: Sun, 3 Mar 2024 16:52:42 +0100 Subject: [PATCH 1/7] Refactor sensor.py --- custom_components/librelink/sensor.py | 303 ++++++++++++-------------- 1 file changed, 139 insertions(+), 164 deletions(-) diff --git a/custom_components/librelink/sensor.py b/custom_components/librelink/sensor.py index 1105b8a..7e9fbf7 100644 --- a/custom_components/librelink/sensor.py +++ b/custom_components/librelink/sensor.py @@ -2,11 +2,11 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone import logging import time -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -51,41 +51,20 @@ async def async_setup_entry( # using an index as we need to keep the coordinator in the @property to get updates from coordinator # we create an array of entities then create entities. - sensors = [] - for index, patients in enumerate(coordinator.data): - - sensors.extend( - [ - LibreLinkSensor( - coordinator, - index, - "value", # key - "Glucose Measurement", # name - custom_unit, - ), - LibreLinkSensor( - coordinator, - index, - "trend", # key - "Trend", # name - custom_unit, - ), - LibreLinkSensor( - coordinator, - index, - "sensor", # key - "Active Sensor", # name - "days", # uom - ), - LibreLinkSensor( - coordinator, - index, - "delay", # key - "Minutes since update", # name - "min", # uom - ), - ] - ) + sensors = [ + sensor + for index, _ in enumerate(coordinator.data) + for sensor in [ + ( + MeasurementMGDLSensor(coordinator, index) + if custom_unit == MG_DL + else MeasurementMMOLSensor(coordinator, index) + ), + TrendSensor(coordinator, index), + ApplicationTimestampSensor(coordinator, index), + LastMeasurementTimestampSensor(coordinator, index), + ] + ] async_add_entities(sensors) @@ -96,150 +75,146 @@ class LibreLinkSensor(LibreLinkDevice, SensorEntity): def __init__( self, coordinator: LibreLinkDataUpdateCoordinator, - index, - key: str, - name: str, - uom, + coordinator_data_index, ) -> None: """Initialize the device class.""" - super().__init__(coordinator, index) - self.uom = uom - self.patients = ( - self.coordinator.data[index]["firstName"] - + " " - + self.coordinator.data[index]["lastName"] - ) - self.patientId = self.coordinator.data[index]["patientId"] - self._attr_unique_id = f"{self.coordinator.data[index]['patientId']}_{key}" - self._attr_name = name - self.index = index - self.key = key + super().__init__(coordinator, coordinator_data_index) - # res = None -# for i, patient in enumerate(self.coordinator.data): -# # for i in range(len(patients)): -# if patient.get("patientId") == "e4a78e05-0780-11ec-ad7d-0242ac110005": -# res = i -# break + self.coordinator_data_index = coordinator_data_index -# _LOGGER.debug( -# "index : %s", -# res, -# ) + self.patient = f'{self._c_data["firstName"]} {self._c_data["lastName"]}' + self.patientId = self._c_data["patientId"] @property - def native_value(self): - """Return the native value of the sensor.""" + def _c_data(self): + return self.coordinator.data[self.coordinator_data_index] - result = None - - if self.patients: - if self.key == "value": - if self.uom == MG_DL: - result = int( - self.coordinator.data[self.index]["glucoseMeasurement"][ - "ValueInMgPerDl" - ] - ) - if self.uom == MMOL_L: - result = round( - float( - self.coordinator.data[self.index][ - "glucoseMeasurement" - ]["ValueInMgPerDl"] - / MMOL_DL_TO_MG_DL - ), - 1, - ) - - elif self.key == "trend": - result = GLUCOSE_TREND_MESSAGE[ - ( - self.coordinator.data[self.index]["glucoseMeasurement"][ - "TrendArrow" - ] - ) - - 1 - ] - - elif self.key == "sensor": - if self.coordinator.data[self.index]["sensor"] != None: - result = int( - ( - time.time() - - (self.coordinator.data[self.index]["sensor"]["a"]) - ) - / 86400 - ) - else: - result = "N/A" - - elif self.key == "delay": - result = int( - ( - datetime.now() - - datetime.strptime( - self.coordinator.data[self.index][ - "glucoseMeasurement" - ]["Timestamp"], - "%m/%d/%Y %I:%M:%S %p", - ) - ).total_seconds() - / 60 # convert seconds in minutes - ) - - return result - return None + @property + def unique_id(self): + return f"{self.patientId} {self.name}".replace(" ", "_").lower() @property def icon(self): """Return the icon for the frontend.""" - - if self.coordinator.data[self.index]["glucoseMeasurement"]["TrendArrow"]: - if self.key in ["value", "trend"]: - return GLUCOSE_TREND_ICON[ - ( - self.coordinator.data[self.index]["glucoseMeasurement"][ - "TrendArrow" - ] - ) - - 1 - ] return GLUCOSE_VALUE_ICON + +class TrendSensor(LibreLinkSensor): + """Glucose Trend Sensor class.""" + + @property + def name(self): + return "Trend" + + @property + def native_value(self): + return GLUCOSE_TREND_MESSAGE[ + (self._c_data["glucoseMeasurement"]["TrendArrow"]) - 1 + ] + + @property + def icon(self): + """Return the icon for the frontend.""" + return GLUCOSE_TREND_ICON[ + (self._c_data["glucoseMeasurement"]["TrendArrow"]) - 1 + ] + + +class MeasurementSensor(TrendSensor, LibreLinkSensor): + """Glucose Measurement Sensor class.""" + + @property + def name(self): + return "Measurement" + + @property + def native_value(self): + """Return the native value of the sensor.""" + return self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] + + +class MeasurementMGDLSensor(MeasurementSensor): + """Glucose Measurement Sensor class.""" + + @property + def suggested_display_precision(self): + """Return the suggested precision of the sensor.""" + return 0 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return MG_DL + + +class MeasurementMMOLSensor(MeasurementSensor): + """Glucose Measurement Sensor class.""" + + @property + def suggested_display_precision(self): + """Return the suggested precision of the sensor.""" + return 1 + + @property + def native_value(self): + """Return the native value of the sensor.""" + return super().native_value / MMOL_DL_TO_MG_DL + @property def unit_of_measurement(self): - """Only used for glucose measurement and librelink sensor delay since update.""" + """Return the unit of measurement of the sensor.""" + return MMOL_L + + +class TimestampSensor(LibreLinkSensor): + @property + def device_class(self): + return SensorDeviceClass.TIMESTAMP - if self.coordinator.data[self.index]: - if self.key in ["sensor", "value"]: - return self.uom - return None + +class ApplicationTimestampSensor(TimestampSensor): + """Sensor Days Sensor class.""" + + @property + def name(self): + return "Application Timestamp" + + @property + def available(self): + """Return if the sensor data are available.""" + return self._c_data["sensor"]["a"] != None + + @property + def native_value(self): + """Return the native value of the sensor.""" + return datetime.fromtimestamp(self._c_data["sensor"]["a"], tz=timezone.utc) @property def extra_state_attributes(self): """Return the state attributes of the librelink sensor.""" - result = None - if self.coordinator.data[self.index]: - if self.key == "sensor": - if self.coordinator.data[self.index]["sensor"] != None: - result = { - "Serial number": f"{self.coordinator.data[self.index]['sensor']['pt']} {self.coordinator.data[self.index]['sensor']['sn']}", - "Activation date": datetime.fromtimestamp( - (self.coordinator.data[self.index]["sensor"]["a"]) - ), - "patientId": self.coordinator.data[self.index]["patientId"], - "Patient": f"{(self.coordinator.data[self.index]['lastName']).upper()} {self.coordinator.data[self.index]['firstName']}", - } - else: - result = { - "Serial number": "N/A", - "Activation date": "N/A", - "patientId": self.coordinator.data[self.index]["patientId"], - "Patient": f"{(self.coordinator.data[self.index]['lastName']).upper()} {self.coordinator.data[self.index]['firstName']}", - } - - - - return result - return result + attrs = { + "patientId": self.patientId, + "Patient": self.patient, + } + if self.available: + attrs |= { + "Serial number": f"{self._c_data['sensor']['pt']} {self._c_data['sensor']['sn']}", + "Activation date": self.native_value, + } + + return attrs + + +class LastMeasurementTimestampSensor(TimestampSensor): + """Sensor Delay Sensor class.""" + + @property + def name(self): + return "Last Measurement Timestamp" + + @property + def native_value(self): + """Return the native value of the sensor.""" + return datetime.strptime( + self._c_data["glucoseMeasurement"]["Timestamp"], "%m/%d/%Y %I:%M:%S %p" + ) From f06f710bb18ad33baa5870f54cf82dd9edbdc2fb Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Fri, 29 Mar 2024 21:54:21 +0100 Subject: [PATCH 2/7] General refactor --- custom_components/librelink/__init__.py | 34 +-- custom_components/librelink/api.py | 266 +++++-------------- custom_components/librelink/binary_sensor.py | 100 +++---- custom_components/librelink/config_flow.py | 32 +-- custom_components/librelink/const.py | 1 - custom_components/librelink/coordinator.py | 22 +- custom_components/librelink/device.py | 1 - custom_components/librelink/sensor.py | 61 +++-- 8 files changed, 180 insertions(+), 337 deletions(-) diff --git a/custom_components/librelink/__init__.py b/custom_components/librelink/__init__.py index b50650b..fdb6c02 100644 --- a/custom_components/librelink/__init__.py +++ b/custom_components/librelink/__init__.py @@ -9,14 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .api import LibreLinkApiClient, LibreLinkApiLogin, LibreLinkGetGraph +from .api import LibreLinkAPI from .const import BASE_URL_LIST, COUNTRY, DOMAIN from .coordinator import LibreLinkDataUpdateCoordinator -PLATFORMS: list[Platform] = [ - Platform.SENSOR, - Platform.BINARY_SENSOR, -] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -35,38 +32,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data.setdefault(DOMAIN, {}) - # Using the declared API for login based on patient credentials to retreive the bearer Token + # Using the declared API for login based on patient credentials to retreive the bearer Token - myLibrelinkLogin = LibreLinkApiLogin( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], + api = LibreLinkAPI( base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), session=async_get_clientsession(hass), ) # Then getting the token. This token is a long life token, so initializaing at HA start up is enough - sessionToken = await myLibrelinkLogin.async_get_token() - - # The retrieved token will be used to initiate the coordinator which will be used to update the data on a regular basis - myLibrelinkClient = LibreLinkApiClient( - sessionToken, - session=async_get_clientsession(hass), - base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), + await api.async_login( + username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD] ) - # Kept for later use in case historical data is needed - # myLibreLinkGetGraph = LibreLinkGetGraph( - # sessionToken, - # session=async_get_clientsession(hass), - # base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), - # patient_id="4cd06c35-28d0-11ec-ae45-0242ac110005", - # ) - # graph = await myLibreLinkGetGraph.async_get_data() - # print(f"graph {graph}") - hass.data[DOMAIN][entry.entry_id] = coordinator = LibreLinkDataUpdateCoordinator( hass=hass, - client=myLibrelinkClient, + api=api, ) # First poll of the data to be ready for entities initialization diff --git a/custom_components/librelink/api.py b/custom_components/librelink/api.py index 28e6f6c..95c5cba 100644 --- a/custom_components/librelink/api.py +++ b/custom_components/librelink/api.py @@ -8,224 +8,94 @@ import aiohttp -from .const import ( - API_TIME_OUT_SECONDS, - APPLICATION, - CONNECTION_URL, - LOGIN_URL, - PRODUCT, - VERSION_APP, -) +from .const import API_TIME_OUT_SECONDS, CONNECTION_URL, LOGIN_URL, PRODUCT, VERSION_APP _LOGGER = logging.getLogger(__name__) -class LibreLinkApiClient: - """API class to retriev measurement data. +class LibreLinkAPIError(Exception): + """Base class for exceptions in this module.""" - Attributes: - token: The long life token to authenticate. - base_url: For API calls depending on your location - Session: aiottp object for the open session - """ - def __init__( - self, token: str, base_url: str, session: aiohttp.ClientSession - ) -> None: - """Sample API Client.""" - self._token = token - self._session = session - self.connection_url = base_url + CONNECTION_URL - - async def async_get_data(self) -> any: - """Get data from the API.""" - APIreponse = await api_wrapper( - self._session, - method="get", - url=self.connection_url, - headers={ - "product": PRODUCT, - "version": VERSION_APP, - "Application": APPLICATION, - "Authorization": "Bearer " + self._token, - }, - data={}, - ) - - # Ordering API response by patients as the API does not always send patients in the same order - # This temporary solution works only when you do not add a new Patient in your account. - # HELP NEEDED - If your fork this project, find a way to navigate through the API response without mixing patients when they arrive in a different order. Strangely, Index numbers are not reevaluated by existing sensors when updated. - # Sorting patients is ok until you add a new patients and then it mixed up indexes. So the solution is to delete the integration and reinstall it when you want to add a patient. +class LibreLinkAPIAuthenticationError(LibreLinkAPIError): + """Exception raised when the API authentication fails.""" - _LOGGER.debug( - "Return API Status:%s ", - APIreponse["status"], - ) - - # API status return 0 if everything goes well. - if APIreponse["status"] == 0: - patients = sorted(APIreponse["data"], key=lambda x: x["patientId"]) - else: - patients = APIreponse # to be used for debugging in status not ok + def __init__(self) -> None: + """Initialize the API error.""" + super().__init__("Invalid credentials") - _LOGGER.debug( - "Number of patients : %s and patient list %s", - len(patients), - patients, - ) - return patients +class LibreLinkAPIConnectionError(LibreLinkAPIError): + """Exception raised when the API connection fails.""" + def __init__(self, message: str = None) -> None: + """Initialize the API error.""" + super().__init__(message or "Connection error") -class LibreLinkGetGraph: - """API class to retriev measurement data. - Attributes: - token: The long life token to authenticate. - base_url: For API calls depending on your location - Session: aiottp object for the open session - patientId: As this API retreive data for a specified patient - """ +class LibreLinkAPI: + """API class for communication with the LibreLink API.""" - def __init__( - self, token: str, base_url: str, session: aiohttp.ClientSession, patient_id: str - ) -> None: - """Sample API Client.""" - self._token = token + def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None: + """Initialize the API client.""" + self._token = None self._session = session - self.connection_url = base_url + CONNECTION_URL - self.patient_id = patient_id + self.base_url = base_url - async def async_get_data(self) -> any: + async def async_get_data(self): """Get data from the API.""" - APIreponse = await api_wrapper( - self._session, - method="get", - url=self.connection_url, - headers={ - "product": PRODUCT, - "version": VERSION_APP, - "Application": APPLICATION, - "Authorization": "Bearer " + self._token, - "patientid": self.patient_id, - }, - data={}, - ) + response = await self._call_api(url=CONNECTION_URL) + _LOGGER.debug("Return API Status:%s ", response["status"]) + # API status return 0 if everything goes well. + if response["status"] != 0: + return response # to be used for debugging in status not ok + patients = sorted(response["data"], key=lambda x: x["patientId"]) _LOGGER.debug( - "Get Connection : %s", - APIreponse, + "Number of patients : %s and patient list %s", len(patients), patients ) + return patients - return APIreponse - - -class LibreLinkApiLogin: - """API class to retriev token. - - Attributes: - username: of the librelink account - password: of the librelink account - base_url: For API calls depending on your location - Session: aiottp object for the open session - """ - - def __init__( - self, - username: str, - password: str, - base_url: str, - session: aiohttp.ClientSession, - ) -> None: - """Sample API Client.""" - self._username = username - self._password = password - self.login_url = base_url + LOGIN_URL - self._session = session - - async def async_get_token(self) -> any: + async def async_login(self, username: str, password: str) -> str: """Get token from the API.""" - reponseLogin = await api_wrapper( - self._session, - method="post", - url=self.login_url, - headers={ - "product": PRODUCT, - "version": VERSION_APP, - "Application": APPLICATION, - }, - data={"email": self._username, "password": self._password}, + response = await self._call_api( + url=LOGIN_URL, + data={"email": username, "password": password}, + authenticated=False, ) - _LOGGER.debug( - "Login status : %s", - reponseLogin["status"], - ) - if reponseLogin["status"]==2: - raise LibreLinkApiAuthenticationError( - "Invalid credentials", - ) - - monToken = reponseLogin["data"]["authTicket"]["token"] - - return monToken - - -################################################################ -# """Utilitises """ # -################################################################ - - -@staticmethod -async def api_wrapper( - session: aiohttp.ClientSession, - method: str, - url: str, - data: dict | None = None, - headers: dict | None = None, -) -> any: - """Get information from the API.""" - try: - async with asyncio.timeout(API_TIME_OUT_SECONDS): - response = await session.request( - method=method, - url=url, - headers=headers, - json=data, - ) - _LOGGER.debug("response.status: %s", response.status) - if response.status in (401, 403): - raise LibreLinkApiAuthenticationError( - "Invalid credentials", - ) - response.raise_for_status() - # - return await response.json() - - except asyncio.TimeoutError as exception: - raise LibreLinkApiCommunicationError( - "Timeout error fetching information", - ) from exception - except (aiohttp.ClientError, socket.gaierror) as exception: - raise LibreLinkApiCommunicationError( - "Error fetching information", - ) from exception - except Exception as exception: # pylint: disable=broad-except - raise LibreLinkApiError("Something really wrong happened!") from exception - + _LOGGER.debug("Login status : %s", response["status"]) + if response["status"] == 2: + raise LibreLinkAPIAuthenticationError() + self._token = response["data"]["authTicket"]["token"] -class LibreLinkApiError(Exception): - """Exception to indicate a general API error.""" - - _LOGGER.debug("Exception: general API error") - - -class LibreLinkApiCommunicationError(LibreLinkApiError): - """Exception to indicate a communication error.""" - - _LOGGER.debug("Exception: communication error") - - -class LibreLinkApiAuthenticationError(LibreLinkApiError): - """Exception to indicate an authentication error.""" - - _LOGGER.debug("Exception: authentication error") + async def _call_api( + self, + url: str, + data: dict | None = None, + authenticated: bool = True, + ) -> any: + """Get information from the API.""" + headers = { + "product": PRODUCT, + "version": VERSION_APP, + } + if authenticated: + headers["Authorization"] = "Bearer " + self._token + + call_method = self._session.post if data else self._session.get + try: + async with asyncio.timeout(API_TIME_OUT_SECONDS): + response = await call_method( + url=self.base_url + url, headers=headers, json=data + ) + _LOGGER.debug("response.status: %s", response.status) + if response.status in (401, 403): + raise LibreLinkAPIAuthenticationError() + response.raise_for_status() + return await response.json() + except TimeoutError as e: + raise LibreLinkAPIConnectionError("Timeout Error") from e + except (aiohttp.ClientError, socket.gaierror) as e: + raise LibreLinkAPIConnectionError() from e + except Exception as e: + raise LibreLinkAPIError() from e diff --git a/custom_components/librelink/binary_sensor.py b/custom_components/librelink/binary_sensor.py index 78614b5..b7f08cc 100644 --- a/custom_components/librelink/binary_sensor.py +++ b/custom_components/librelink/binary_sensor.py @@ -2,18 +2,16 @@ from __future__ import annotations -import logging - -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import LibreLinkDataUpdateCoordinator -from .device import LibreLinkDevice - -_LOGGER = logging.getLogger(__name__) +from .sensor import LibreLinkSensorBase async def async_setup_entry( @@ -27,60 +25,50 @@ async def async_setup_entry( # to manage multiple patients, the API return an array of patients in "data". So we loop in the array # and create as many devices and sensors as we do have patients. - sensors = [] - # Loop through list of patients which are under "Data" - for index, _ in enumerate(coordinator.data): - sensors.extend( - [ - LibreLinkBinarySensor( - coordinator, - index, - key="isHigh", - name="Is High", - ), - LibreLinkBinarySensor( - coordinator, - index, - key="isLow", - name="Is Low", - ), - ] - ) + sensors = [ + sensor + # Loop through list of patients which are under "Data" + for index, _ in enumerate(coordinator.data) + for sensor in [ + HighSensor(coordinator, index), + LowSensor(coordinator, index), + ] + ] async_add_entities(sensors) -class LibreLinkBinarySensor(LibreLinkDevice, BinarySensorEntity): - """librelink binary_sensor class.""" - - def __init__( - self, - coordinator: LibreLinkDataUpdateCoordinator, - index: int, - key: str, - name: str, - ) -> None: - """Initialize the device class.""" - super().__init__(coordinator, index) - - self.key = key - self.patients = ( - coordinator.data[index]["firstName"] - + " " - + coordinator.data[index]["lastName"] - ) - self.patientId = self.coordinator.data[index]["patientId"] - self.index = index - self._attr_name = name - self.coordinator = coordinator - - # define unique_id based on patient id and sensor key +class LibreLinkBinarySensor(LibreLinkSensorBase, BinarySensorEntity): + """LibreLink Binary Sensor class.""" + + @property + def device_class(self) -> str: + """Return the class of this device.""" + return BinarySensorDeviceClass.SAFETY + + +class HighSensor(LibreLinkBinarySensor): + """High Sensor class.""" + + @property + def name(self) -> str: + """Return the name of the binary_sensor.""" + return "Is High" + + @property + def is_on(self) -> bool: + """Return true if the binary_sensor is on.""" + return self._c_data["glucoseMeasurement"]["isHigh"] + + +class LowSensor(LibreLinkBinarySensor): + """Low Sensor class.""" + @property - def unique_id(self) -> str: - """Return a unique id for the sensor.""" - return f"{self.coordinator.data[self.index]['patientId']}_{self.key}" + def name(self) -> str: + """Return the name of the binary_sensor.""" + return "Is Low" - # define state based on the entity_description key @property def is_on(self) -> bool: """Return true if the binary_sensor is on.""" - return self.coordinator.data[self.index]["glucoseMeasurement"][self.key] + return self._c_data["glucoseMeasurement"]["isLow"] diff --git a/custom_components/librelink/config_flow.py b/custom_components/librelink/config_flow.py index 25f26d0..a492d76 100644 --- a/custom_components/librelink/config_flow.py +++ b/custom_components/librelink/config_flow.py @@ -8,14 +8,14 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession from .api import ( - LibreLinkApiAuthenticationError, - LibreLinkApiCommunicationError, - LibreLinkApiError, - LibreLinkApiLogin, + LibreLinkAPI, + LibreLinkAPIAuthenticationError, + LibreLinkAPIConnectionError, + LibreLinkAPIError, ) from .const import BASE_URL_LIST, COUNTRY, COUNTRY_LIST, DOMAIN, LOGGER, MG_DL, MMOL_L @@ -41,14 +41,14 @@ async def async_step_user( password=user_input[CONF_PASSWORD], base_url=BASE_URL_LIST.get(user_input[COUNTRY]), ) - except LibreLinkApiAuthenticationError as exception: - LOGGER.warning(exception) + except LibreLinkAPIAuthenticationError as e: + LOGGER.warning(e) _errors["base"] = "auth" - except LibreLinkApiCommunicationError as exception: - LOGGER.error(exception) + except LibreLinkAPIConnectionError as e: + LOGGER.error(e) _errors["base"] = "connection" - except LibreLinkApiError as exception: - LOGGER.exception(exception) + except LibreLinkAPIError as e: + LOGGER.exception(e) _errors["base"] = "unknown" else: return self.async_create_entry( @@ -91,12 +91,8 @@ async def _test_credentials( self, username: str, password: str, base_url: str ) -> None: """Validate credentials.""" - client = LibreLinkApiLogin( - username=username, - password=password, - base_url=base_url, - session=async_create_clientsession(self.hass), + client = LibreLinkAPI( + base_url=base_url, session=async_create_clientsession(self.hass) ) - await client.async_get_token() - + await client.async_login(username, password) diff --git a/custom_components/librelink/const.py b/custom_components/librelink/const.py index b9fc489..9ca021b 100644 --- a/custom_components/librelink/const.py +++ b/custom_components/librelink/const.py @@ -39,7 +39,6 @@ } PRODUCT = "llu.android" VERSION_APP = "4.7" -APPLICATION = "application/json" GLUCOSE_VALUE_ICON = "mdi:diabetes" GLUCOSE_TREND_ICON = [ "mdi:arrow-down-bold-box", diff --git a/custom_components/librelink/coordinator.py b/custom_components/librelink/coordinator.py index 4245d14..82b5451 100644 --- a/custom_components/librelink/coordinator.py +++ b/custom_components/librelink/coordinator.py @@ -3,18 +3,14 @@ from __future__ import annotations from datetime import timedelta -import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .api import LibreLinkApiAuthenticationError, LibreLinkApiClient, LibreLinkApiError +from .api import LibreLinkAPI from .const import DOMAIN, LOGGER, REFRESH_RATE_MIN -_LOGGER = logging.getLogger(__name__) - class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API. single endpoint.""" @@ -24,11 +20,10 @@ class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - client: LibreLinkApiClient, + api: LibreLinkAPI, ) -> None: """Initialize.""" - self.client = client - self.api: LibreLinkApiClient = client + self.api: LibreLinkAPI = api super().__init__( hass=hass, @@ -39,11 +34,4 @@ def __init__( async def _async_update_data(self): """Update data via library.""" - try: - return await self.client.async_get_data() - except LibreLinkApiAuthenticationError as exception: - _LOGGER.debug("Exception: authentication error during coordinator update") - raise ConfigEntryAuthFailed(exception) from exception - except LibreLinkApiError as exception: - _LOGGER.debug("Exception: general API error during coordinator update") - raise UpdateFailed(exception) from exception + return await self.api.async_get_data() diff --git a/custom_components/librelink/device.py b/custom_components/librelink/device.py index b77d157..cf78b6d 100644 --- a/custom_components/librelink/device.py +++ b/custom_components/librelink/device.py @@ -48,4 +48,3 @@ def __init__( model=VERSION, manufacturer=NAME, ) - diff --git a/custom_components/librelink/sensor.py b/custom_components/librelink/sensor.py index 7e9fbf7..ee48dd6 100644 --- a/custom_components/librelink/sensor.py +++ b/custom_components/librelink/sensor.py @@ -2,11 +2,14 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime, timedelta import logging -import time -from homeassistant.components.sensor import SensorEntity, SensorDeviceClass +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -62,6 +65,7 @@ async def async_setup_entry( ), TrendSensor(coordinator, index), ApplicationTimestampSensor(coordinator, index), + ExpirationTimestampSensor(coordinator, index), LastMeasurementTimestampSensor(coordinator, index), ] ] @@ -69,13 +73,11 @@ async def async_setup_entry( async_add_entities(sensors) -class LibreLinkSensor(LibreLinkDevice, SensorEntity): - """LibreLink Sensor class.""" +class LibreLinkSensorBase(LibreLinkDevice): + """LibreLink Sensor base class.""" def __init__( - self, - coordinator: LibreLinkDataUpdateCoordinator, - coordinator_data_index, + self, coordinator: LibreLinkDataUpdateCoordinator, coordinator_data_index: int ) -> None: """Initialize the device class.""" super().__init__(coordinator, coordinator_data_index) @@ -93,6 +95,10 @@ def _c_data(self): def unique_id(self): return f"{self.patientId} {self.name}".replace(" ", "_").lower() + +class LibreLinkSensor(LibreLinkSensorBase, SensorEntity): + """LibreLink Sensor class.""" + @property def icon(self): """Return the icon for the frontend.""" @@ -120,9 +126,13 @@ def icon(self): ] -class MeasurementSensor(TrendSensor, LibreLinkSensor): +class MeasurementMGDLSensor(TrendSensor, LibreLinkSensor): """Glucose Measurement Sensor class.""" + @property + def state_class(self): + return SensorStateClass.MEASUREMENT + @property def name(self): return "Measurement" @@ -132,10 +142,6 @@ def native_value(self): """Return the native value of the sensor.""" return self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] - -class MeasurementMGDLSensor(MeasurementSensor): - """Glucose Measurement Sensor class.""" - @property def suggested_display_precision(self): """Return the suggested precision of the sensor.""" @@ -147,7 +153,7 @@ def unit_of_measurement(self): return MG_DL -class MeasurementMMOLSensor(MeasurementSensor): +class MeasurementMMOLSensor(MeasurementMGDLSensor): """Glucose Measurement Sensor class.""" @property @@ -167,6 +173,8 @@ def unit_of_measurement(self): class TimestampSensor(LibreLinkSensor): + """Timestamp Sensor class.""" + @property def device_class(self): return SensorDeviceClass.TIMESTAMP @@ -182,12 +190,12 @@ def name(self): @property def available(self): """Return if the sensor data are available.""" - return self._c_data["sensor"]["a"] != None + return self._c_data["sensor"]["a"] is not None @property def native_value(self): """Return the native value of the sensor.""" - return datetime.fromtimestamp(self._c_data["sensor"]["a"], tz=timezone.utc) + return datetime.fromtimestamp(self._c_data["sensor"]["a"], tz=UTC) @property def extra_state_attributes(self): @@ -199,12 +207,25 @@ def extra_state_attributes(self): if self.available: attrs |= { "Serial number": f"{self._c_data['sensor']['pt']} {self._c_data['sensor']['sn']}", - "Activation date": self.native_value, + "Activation date": ApplicationTimestampSensor.native_value.fget(self), } return attrs +class ExpirationTimestampSensor(ApplicationTimestampSensor): + """Sensor Days Sensor class.""" + + @property + def name(self): + return "Expiration Timestamp" + + @property + def native_value(self): + """Return the native value of the sensor.""" + return super().native_value + timedelta(days=14) + + class LastMeasurementTimestampSensor(TimestampSensor): """Sensor Delay Sensor class.""" @@ -215,6 +236,8 @@ def name(self): @property def native_value(self): """Return the native value of the sensor.""" + return datetime.strptime( - self._c_data["glucoseMeasurement"]["Timestamp"], "%m/%d/%Y %I:%M:%S %p" - ) + self._c_data["glucoseMeasurement"]["FactoryTimestamp"], + "%m/%d/%Y %I:%M:%S %p", + ).replace(tzinfo=UTC) From f6203a5552e2dddb1799a0ad914ec27cc2d73690 Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Fri, 29 Mar 2024 22:34:00 +0100 Subject: [PATCH 3/7] Alerts fix --- custom_components/librelink/binary_sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/custom_components/librelink/binary_sensor.py b/custom_components/librelink/binary_sensor.py index b7f08cc..b991336 100644 --- a/custom_components/librelink/binary_sensor.py +++ b/custom_components/librelink/binary_sensor.py @@ -45,6 +45,11 @@ def device_class(self) -> str: """Return the class of this device.""" return BinarySensorDeviceClass.SAFETY + @property + def _current_glucose(self) -> int: + """Return the current glucose value.""" + return self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] + class HighSensor(LibreLinkBinarySensor): """High Sensor class.""" @@ -57,7 +62,7 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if the binary_sensor is on.""" - return self._c_data["glucoseMeasurement"]["isHigh"] + return self._current_glucose >= self._c_data["targetHigh"] class LowSensor(LibreLinkBinarySensor): @@ -71,4 +76,4 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if the binary_sensor is on.""" - return self._c_data["glucoseMeasurement"]["isLow"] + return self._current_glucose <= self._c_data["targetLow"] From 728493ef95079724aa9b746ed1ee7b7eb2f8dd0f Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Mon, 1 Apr 2024 22:56:02 +0200 Subject: [PATCH 4/7] Multistep config proto --- custom_components/librelink/api.py | 27 ++++--- custom_components/librelink/config_flow.py | 72 ++++++++++++------ custom_components/librelink/const.py | 32 ++++---- custom_components/librelink/device.py | 14 ++-- custom_components/librelink/sensor.py | 75 +++++++------------ .../librelink/translations/pl.json | 22 ++++++ custom_components/librelink/units.py | 25 +++++++ 7 files changed, 164 insertions(+), 103 deletions(-) create mode 100644 custom_components/librelink/translations/pl.json create mode 100644 custom_components/librelink/units.py diff --git a/custom_components/librelink/api.py b/custom_components/librelink/api.py index 95c5cba..0dcda52 100644 --- a/custom_components/librelink/api.py +++ b/custom_components/librelink/api.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import socket @@ -50,10 +49,13 @@ async def async_get_data(self): if response["status"] != 0: return response # to be used for debugging in status not ok - patients = sorted(response["data"], key=lambda x: x["patientId"]) + patients = response["data"] _LOGGER.debug( "Number of patients : %s and patient list %s", len(patients), patients ) + + self._token = response["ticket"]["token"] + return patients async def async_login(self, username: str, password: str) -> str: @@ -66,6 +68,7 @@ async def async_login(self, username: str, password: str) -> str: _LOGGER.debug("Login status : %s", response["status"]) if response["status"] == 2: raise LibreLinkAPIAuthenticationError() + self._token = response["data"]["authTicket"]["token"] async def _call_api( @@ -84,15 +87,17 @@ async def _call_api( call_method = self._session.post if data else self._session.get try: - async with asyncio.timeout(API_TIME_OUT_SECONDS): - response = await call_method( - url=self.base_url + url, headers=headers, json=data - ) - _LOGGER.debug("response.status: %s", response.status) - if response.status in (401, 403): - raise LibreLinkAPIAuthenticationError() - response.raise_for_status() - return await response.json() + response = await call_method( + url=self.base_url + url, + headers=headers, + json=data, + timeout=aiohttp.ClientTimeout(total=API_TIME_OUT_SECONDS), + ) + _LOGGER.debug("response.status: %s", response.status) + if response.status in (401, 403): + raise LibreLinkAPIAuthenticationError() + response.raise_for_status() + return await response.json() except TimeoutError as e: raise LibreLinkAPIConnectionError("Timeout Error") from e except (aiohttp.ClientError, socket.gaierror) as e: diff --git a/custom_components/librelink/config_flow.py b/custom_components/librelink/config_flow.py index a492d76..e50b46e 100644 --- a/custom_components/librelink/config_flow.py +++ b/custom_components/librelink/config_flow.py @@ -10,6 +10,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .api import ( LibreLinkAPI, @@ -17,7 +23,8 @@ LibreLinkAPIConnectionError, LibreLinkAPIError, ) -from .const import BASE_URL_LIST, COUNTRY, COUNTRY_LIST, DOMAIN, LOGGER, MG_DL, MMOL_L +from .const import BASE_URL_LIST, COUNTRY, COUNTRY_LIST, DOMAIN, LOGGER +from .units import UNITS_OF_MEASUREMENT # GVS: Init logger _LOGGER = logging.getLogger(__name__) @@ -36,11 +43,18 @@ async def async_step_user( _errors = {} if user_input is not None: try: - await self._test_credentials( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - base_url=BASE_URL_LIST.get(user_input[COUNTRY]), + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + base_url = BASE_URL_LIST[user_input[COUNTRY]] + + client = LibreLinkAPI( + base_url=base_url, session=async_create_clientsession(self.hass) ) + await client.async_login(username, password) + + self.client = client + + return await self.async_step_patient() except LibreLinkAPIAuthenticationError as e: LOGGER.warning(e) _errors["base"] = "auth" @@ -50,11 +64,6 @@ async def async_step_user( except LibreLinkAPIError as e: LOGGER.exception(e) _errors["base"] = "unknown" - else: - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=user_input, - ) return self.async_show_form( step_id="user", @@ -78,21 +87,42 @@ async def async_step_user( description="Country", default=(COUNTRY_LIST[0]), ): vol.In(COUNTRY_LIST), - vol.Required( - CONF_UNIT_OF_MEASUREMENT, - default=(MG_DL), - ): vol.In({MG_DL, MMOL_L}), } ), errors=_errors, ) - async def _test_credentials( - self, username: str, password: str, base_url: str - ) -> None: - """Validate credentials.""" - client = LibreLinkAPI( - base_url=base_url, session=async_create_clientsession(self.hass) + async def async_step_patient(self, user_input=None): + if user_input and "PATIENT_ID" in user_input: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + data = await self.client.async_get_data() + + c1 = SelectSelectorConfig( + options=[ + SelectOptionDict( + value=patient["patientId"], + label=f'{patient['firstName']} {patient["lastName"]}', + ) + for patient in data + ] + ) + c2 = SelectSelectorConfig( + options=[ + SelectOptionDict(value=k, label=u.unit_of_measurement) + for k, u in UNITS_OF_MEASUREMENT.items() + ] ) - await client.async_login(username, password) + return self.async_show_form( + step_id="patient", + data_schema=vol.Schema( + { + vol.Required("PATIENT_ID"): SelectSelector(c1), + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector(c2), + } + ), + ) diff --git a/custom_components/librelink/const.py b/custom_components/librelink/const.py index 9ca021b..b850f2c 100644 --- a/custom_components/librelink/const.py +++ b/custom_components/librelink/const.py @@ -40,22 +40,20 @@ PRODUCT = "llu.android" VERSION_APP = "4.7" GLUCOSE_VALUE_ICON = "mdi:diabetes" -GLUCOSE_TREND_ICON = [ - "mdi:arrow-down-bold-box", - "mdi:arrow-bottom-right-bold-box", - "mdi:arrow-right-bold-box", - "mdi:arrow-top-right-bold-box", - "mdi:arrow-up-bold-box", -] -GLUCOSE_TREND_MESSAGE = [ - "Decreasing fast", - "Decreasing", - "Stable", - "Increasing", - "Increasing fast", -] -MMOL_L = "mmol/L" -MG_DL = "mg/dL" -MMOL_DL_TO_MG_DL = 18 +GLUCOSE_TREND_ICON = { + 1: "mdi:arrow-down-bold-box", + 2: "mdi:arrow-bottom-right-bold-box", + 3: "mdi:arrow-right-bold-box", + 4: "mdi:arrow-top-right-bold-box", + 5: "mdi:arrow-up-bold-box", +} +GLUCOSE_TREND_MESSAGE = { + 1: "Decreasing fast", + 2: "Decreasing", + 3: "Stable", + 4: "Increasing", + 5: "Increasing fast", +} + REFRESH_RATE_MIN = 1 API_TIME_OUT_SECONDS = 20 diff --git a/custom_components/librelink/device.py b/custom_components/librelink/device.py index cf78b6d..175c740 100644 --- a/custom_components/librelink/device.py +++ b/custom_components/librelink/device.py @@ -1,17 +1,14 @@ """Sensor platform for LibreLink.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo - -from .const import ATTRIBUTION, DOMAIN, NAME, VERSION -from .coordinator import LibreLinkDataUpdateCoordinator +import logging +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import ATTRIBUTION, DOMAIN, NAME, VERSION from .coordinator import LibreLinkDataUpdateCoordinator -import logging - # enable logging _LOGGER = logging.getLogger(__name__) @@ -19,6 +16,7 @@ # This class is called when a device is created. # A device is created for each patient to regroup patient entities + class LibreLinkDevice(CoordinatorEntity): """LibreLinkEntity class.""" @@ -44,7 +42,9 @@ def __init__( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.coordinator.data[index]["patientId"])}, - name=self.coordinator.data[index]["firstName"] + " " + self.coordinator.data[index]["lastName"], + name=self.coordinator.data[index]["firstName"] + + " " + + self.coordinator.data[index]["lastName"], model=VERSION, manufacturer=NAME, ) diff --git a/custom_components/librelink/sensor.py b/custom_components/librelink/sensor.py index ee48dd6..51a53a8 100644 --- a/custom_components/librelink/sensor.py +++ b/custom_components/librelink/sensor.py @@ -15,17 +15,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DOMAIN, - GLUCOSE_TREND_ICON, - GLUCOSE_TREND_MESSAGE, - GLUCOSE_VALUE_ICON, - MG_DL, - MMOL_DL_TO_MG_DL, - MMOL_L, -) +from .const import DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_TREND_MESSAGE, GLUCOSE_VALUE_ICON from .coordinator import LibreLinkDataUpdateCoordinator from .device import LibreLinkDevice +from .units import UNITS_OF_MEASUREMENT, UnitOfMeasurement # GVS: Tuto pour ajouter des log _LOGGER = logging.getLogger(__name__) @@ -45,10 +38,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] # If custom unit of measurement is selectid it is initialized, otherwise MG/DL is used - try: - custom_unit = config_entry.data[CONF_UNIT_OF_MEASUREMENT] - except KeyError: - custom_unit = MG_DL + unit = UNITS_OF_MEASUREMENT[config_entry.data[CONF_UNIT_OF_MEASUREMENT]] # For each patients, new Device base on patients and # using an index as we need to keep the coordinator in the @property to get updates from coordinator @@ -58,11 +48,7 @@ async def async_setup_entry( sensor for index, _ in enumerate(coordinator.data) for sensor in [ - ( - MeasurementMGDLSensor(coordinator, index) - if custom_unit == MG_DL - else MeasurementMMOLSensor(coordinator, index) - ), + MeasurementSensor(coordinator, index, unit), TrendSensor(coordinator, index), ApplicationTimestampSensor(coordinator, index), ExpirationTimestampSensor(coordinator, index), @@ -93,6 +79,7 @@ def _c_data(self): @property def unique_id(self): + """Return the unique id of the sensor.""" return f"{self.patientId} {self.name}".replace(" ", "_").lower() @@ -110,66 +97,56 @@ class TrendSensor(LibreLinkSensor): @property def name(self): + """Return the name of the sensor.""" return "Trend" @property def native_value(self): - return GLUCOSE_TREND_MESSAGE[ - (self._c_data["glucoseMeasurement"]["TrendArrow"]) - 1 - ] + """Return the native value of the sensor.""" + return GLUCOSE_TREND_MESSAGE[(self._c_data["glucoseMeasurement"]["TrendArrow"])] @property def icon(self): """Return the icon for the frontend.""" - return GLUCOSE_TREND_ICON[ - (self._c_data["glucoseMeasurement"]["TrendArrow"]) - 1 - ] + return GLUCOSE_TREND_ICON[(self._c_data["glucoseMeasurement"]["TrendArrow"])] -class MeasurementMGDLSensor(TrendSensor, LibreLinkSensor): +class MeasurementSensor(TrendSensor, LibreLinkSensor): """Glucose Measurement Sensor class.""" + def __init__( + self, coordinator, coordinator_data_index, unit: UnitOfMeasurement + ) -> None: + """Initialize the sensor class.""" + super().__init__(coordinator, coordinator_data_index) + self.unit = unit + @property def state_class(self): + """Return the state class of the sensor.""" return SensorStateClass.MEASUREMENT @property def name(self): + """Return the name of the sensor.""" return "Measurement" @property def native_value(self): """Return the native value of the sensor.""" - return self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] + return self.unit.from_mg_per_dl( + self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] + ) @property def suggested_display_precision(self): """Return the suggested precision of the sensor.""" - return 0 - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return MG_DL - - -class MeasurementMMOLSensor(MeasurementMGDLSensor): - """Glucose Measurement Sensor class.""" - - @property - def suggested_display_precision(self): - """Return the suggested precision of the sensor.""" - return 1 - - @property - def native_value(self): - """Return the native value of the sensor.""" - return super().native_value / MMOL_DL_TO_MG_DL + return self.unit.suggested_display_precision @property def unit_of_measurement(self): """Return the unit of measurement of the sensor.""" - return MMOL_L + return self.unit_of_measurement class TimestampSensor(LibreLinkSensor): @@ -177,6 +154,7 @@ class TimestampSensor(LibreLinkSensor): @property def device_class(self): + """Return the device class of the sensor.""" return SensorDeviceClass.TIMESTAMP @@ -185,6 +163,7 @@ class ApplicationTimestampSensor(TimestampSensor): @property def name(self): + """Return the name of the sensor.""" return "Application Timestamp" @property @@ -218,6 +197,7 @@ class ExpirationTimestampSensor(ApplicationTimestampSensor): @property def name(self): + """Return the name of the sensor.""" return "Expiration Timestamp" @property @@ -231,6 +211,7 @@ class LastMeasurementTimestampSensor(TimestampSensor): @property def name(self): + """Return the name of the sensor.""" return "Last Measurement Timestamp" @property diff --git a/custom_components/librelink/translations/pl.json b/custom_components/librelink/translations/pl.json new file mode 100644 index 0000000..04dbbf4 --- /dev/null +++ b/custom_components/librelink/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Wpisz email i hasło do konta LibreLink.", + "description": "Dokumentacja: https://github.com/gillesvs/librelink", + "data": { + "username": "Email", + "password": "Hasło" + } + } + }, + "error": { + "auth": "Podany email lub hasło są niepoprawne.", + "connection": "Nie można połączyć się z serwerem LibreLink.", + "unknown": "Wystąpił nieznany błąd." + }, + "abort": { + "already_configured": "Urządzenie jest już skonfigurowane." + } + } +} diff --git a/custom_components/librelink/units.py b/custom_components/librelink/units.py new file mode 100644 index 0000000..9e54211 --- /dev/null +++ b/custom_components/librelink/units.py @@ -0,0 +1,25 @@ +"""Units of measurement for LibreLink integration.""" +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass +class UnitOfMeasurement: + """Unit of measurement for LibreLink integration.""" + + unit_of_measurement: str + suggested_display_precision: int + from_mg_per_dl: Callable[[float], float] = lambda x: x + + +UNITS_OF_MEASUREMENT = { + "mgdl": UnitOfMeasurement( + unit_of_measurement="mg/dL", + suggested_display_precision=0, + ), + "mmoll": UnitOfMeasurement( + unit_of_measurement="mmol/L", + suggested_display_precision=1, + from_mg_per_dl=lambda x: x / 18, + ), +} From 3881c679bb13fd713ae61fa51733dae980e56f7a Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Tue, 2 Apr 2024 13:22:52 +0200 Subject: [PATCH 5/7] Dataclasses --- custom_components/librelink/__init__.py | 70 +++++------ custom_components/librelink/api.py | 104 ++++++++++++++-- custom_components/librelink/binary_sensor.py | 30 ++--- custom_components/librelink/config_flow.py | 98 ++++++++------- custom_components/librelink/const.py | 46 +++---- custom_components/librelink/coordinator.py | 24 +++- custom_components/librelink/device.py | 50 -------- custom_components/librelink/sensor.py | 124 ++++++++++--------- custom_components/librelink/units.py | 11 +- 9 files changed, 298 insertions(+), 259 deletions(-) delete mode 100644 custom_components/librelink/device.py diff --git a/custom_components/librelink/__init__.py b/custom_components/librelink/__init__.py index fdb6c02..8e4955c 100644 --- a/custom_components/librelink/__init__.py +++ b/custom_components/librelink/__init__.py @@ -2,73 +2,67 @@ from __future__ import annotations -import logging - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import LibreLinkAPI -from .const import BASE_URL_LIST, COUNTRY, DOMAIN +from .const import CONF_PATIENT_ID, DOMAIN, LOGGER from .coordinator import LibreLinkDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" - _LOGGER.debug( - "Appel de async_setup_entry entry: entry_id= %s, data= %s, user = %s BaseUrl = %s", + LOGGER.debug( + "Appel de async_setup_entry entry: entry_id= %s, data= %s", entry.entry_id, entry.data, - entry.data[CONF_USERNAME], - # entry.data[CONF_PASSWORD], - BASE_URL_LIST.get(entry.data[COUNTRY]), ) - hass.data.setdefault(DOMAIN, {}) - # Using the declared API for login based on patient credentials to retreive the bearer Token + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + base_url = entry.data[CONF_URL] + patient_id = entry.data[CONF_PATIENT_ID] - api = LibreLinkAPI( - base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), - session=async_get_clientsession(hass), - ) + domain_data = hass.data.setdefault(DOMAIN, {}) - # Then getting the token. This token is a long life token, so initializaing at HA start up is enough - await api.async_login( - username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD] - ) + if username not in domain_data: + # Using the declared API for login based on patient credentials to retreive the bearer Token + api = LibreLinkAPI( + base_url=base_url, + session=async_get_clientsession(hass), + ) - hass.data[DOMAIN][entry.entry_id] = coordinator = LibreLinkDataUpdateCoordinator( - hass=hass, - api=api, - ) + # Then getting the token. + await api.async_login(username=username, password=password) - # First poll of the data to be ready for entities initialization - await coordinator.async_config_entry_first_refresh() + coordinator = LibreLinkDataUpdateCoordinator( + hass=hass, api=api, patient_id=patient_id + ) + + # First poll of the data to be ready for entities initialization + await coordinator.async_config_entry_first_refresh() + + domain_data[username] = coordinator + else: + coordinator: LibreLinkDataUpdateCoordinator = domain_data[username] + coordinator.register_patient(patient_id) # Then launch async_setup_entry for our declared entities in sensor.py and binary_sensor.py await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when its updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: LibreLinkDataUpdateCoordinator = hass.data[DOMAIN][CONF_USERNAME] + coordinator.unregister_patient(entry.data[CONF_PATIENT_ID]) + if coordinator.tracked_patients == 0: + hass.data[DOMAIN].pop(CONF_USERNAME) return unloaded - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry when it changed.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) diff --git a/custom_components/librelink/api.py b/custom_components/librelink/api.py index 0dcda52..d65ae01 100644 --- a/custom_components/librelink/api.py +++ b/custom_components/librelink/api.py @@ -2,14 +2,94 @@ from __future__ import annotations -import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta import socket import aiohttp -from .const import API_TIME_OUT_SECONDS, CONNECTION_URL, LOGIN_URL, PRODUCT, VERSION_APP - -_LOGGER = logging.getLogger(__name__) +from .const import ( + API_TIME_OUT_SECONDS, + CONNECTION_URL, + LOGGER, + LOGIN_URL, + PRODUCT, + VERSION_APP, +) + + +@dataclass +class Target: + """Target Glucose data.""" + + high: int + low: int + + +@dataclass +class Measurement: + """Measurement data.""" + + value: int + timestamp: datetime + trend: int + + +@dataclass +class LibreLinkDevice: + """LibreLink device data.""" + + serial_number: str + application_timestamp: datetime | None + + @property + def expiration_timestamp(self): + """Return the expiration timestamp of the sensor.""" + return self.application_timestamp + timedelta(days=14) + + +@dataclass +class Patient: + """Patient data.""" + + id: str + first_name: str + last_name: str + measurement: Measurement + target: Target + device: LibreLinkDevice + + @property + def name(self): + """Return the full name of the patient.""" + return f"{self.first_name} {self.last_name}" + + @classmethod + def from_api_response_data(cls, data): + """Create a Patient object from the API response data.""" + return cls( + id=data["patientId"], + first_name=data["firstName"], + last_name=data["lastName"], + measurement=Measurement( + value=data["glucoseMeasurement"]["ValueInMgPerDl"], + timestamp=datetime.strptime( + data["glucoseMeasurement"]["FactoryTimestamp"], + "%m/%d/%Y %I:%M:%S %p", + ).replace(tzinfo=UTC), + trend=data["glucoseMeasurement"]["TrendArrow"], + ), + target=Target( + high=data["targetHigh"], + low=data["targetLow"], + ), + device=LibreLinkDevice( + serial_number=f'{data["sensor"]["pt"]}{data['sensor']['sn']}', + application_timestamp=datetime.fromtimestamp( + data["sensor"]["a"], tz=UTC + ), + ), + ) class LibreLinkAPIError(Exception): @@ -44,16 +124,18 @@ def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None: async def async_get_data(self): """Get data from the API.""" response = await self._call_api(url=CONNECTION_URL) - _LOGGER.debug("Return API Status:%s ", response["status"]) + LOGGER.debug("Return API Status:%s ", response["status"]) # API status return 0 if everything goes well. if response["status"] != 0: - return response # to be used for debugging in status not ok + raise LibreLinkAPIConnectionError() - patients = response["data"] - _LOGGER.debug( + patients = [ + Patient.from_api_response_data(patient) for patient in response["data"] + ] + + LOGGER.debug( "Number of patients : %s and patient list %s", len(patients), patients ) - self._token = response["ticket"]["token"] return patients @@ -65,7 +147,7 @@ async def async_login(self, username: str, password: str) -> str: data={"email": username, "password": password}, authenticated=False, ) - _LOGGER.debug("Login status : %s", response["status"]) + LOGGER.debug("Login status : %s", response["status"]) if response["status"] == 2: raise LibreLinkAPIAuthenticationError() @@ -93,7 +175,7 @@ async def _call_api( json=data, timeout=aiohttp.ClientTimeout(total=API_TIME_OUT_SECONDS), ) - _LOGGER.debug("response.status: %s", response.status) + LOGGER.debug("response.status: %s", response.status) if response.status in (401, 403): raise LibreLinkAPIAuthenticationError() response.raise_for_status() diff --git a/custom_components/librelink/binary_sensor.py b/custom_components/librelink/binary_sensor.py index b991336..bf1bba9 100644 --- a/custom_components/librelink/binary_sensor.py +++ b/custom_components/librelink/binary_sensor.py @@ -7,10 +7,12 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import CONF_PATIENT_ID, DOMAIN +from .coordinator import LibreLinkDataUpdateCoordinator from .sensor import LibreLinkSensorBase @@ -21,18 +23,15 @@ async def async_setup_entry( ): """Set up the binary_sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: LibreLinkDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.data[CONF_USERNAME] + ] + + pid = config_entry.data[CONF_PATIENT_ID] - # to manage multiple patients, the API return an array of patients in "data". So we loop in the array - # and create as many devices and sensors as we do have patients. sensors = [ - sensor - # Loop through list of patients which are under "Data" - for index, _ in enumerate(coordinator.data) - for sensor in [ - HighSensor(coordinator, index), - LowSensor(coordinator, index), - ] + HighSensor(coordinator, pid), + LowSensor(coordinator, pid), ] async_add_entities(sensors) @@ -45,11 +44,6 @@ def device_class(self) -> str: """Return the class of this device.""" return BinarySensorDeviceClass.SAFETY - @property - def _current_glucose(self) -> int: - """Return the current glucose value.""" - return self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] - class HighSensor(LibreLinkBinarySensor): """High Sensor class.""" @@ -62,7 +56,7 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if the binary_sensor is on.""" - return self._current_glucose >= self._c_data["targetHigh"] + return self._data.measurement.value >= self._data.target.high class LowSensor(LibreLinkBinarySensor): @@ -76,4 +70,4 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if the binary_sensor is on.""" - return self._current_glucose <= self._c_data["targetLow"] + return self._data.measurement.value <= self._data.target.low diff --git a/custom_components/librelink/config_flow.py b/custom_components/librelink/config_flow.py index e50b46e..541ea07 100644 --- a/custom_components/librelink/config_flow.py +++ b/custom_components/librelink/config_flow.py @@ -2,19 +2,24 @@ from __future__ import annotations -import logging - import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME -from homeassistant.helpers import selector +from homeassistant.const import ( + CONF_PASSWORD, + CONF_UNIT_OF_MEASUREMENT, + CONF_URL, + CONF_USERNAME, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from .api import ( @@ -23,12 +28,9 @@ LibreLinkAPIConnectionError, LibreLinkAPIError, ) -from .const import BASE_URL_LIST, COUNTRY, COUNTRY_LIST, DOMAIN, LOGGER +from .const import BASE_URL_LIST, CONF_PATIENT_ID, DOMAIN, LOGGER from .units import UNITS_OF_MEASUREMENT -# GVS: Init logger -_LOGGER = logging.getLogger(__name__) - class LibreLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for LibreLink.""" @@ -45,14 +47,15 @@ async def async_step_user( try: username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - base_url = BASE_URL_LIST[user_input[COUNTRY]] + base_url = user_input[CONF_URL] client = LibreLinkAPI( base_url=base_url, session=async_create_clientsession(self.hass) ) await client.async_login(username, password) - self.client = client + self.patients = await client.async_get_data() + self.basic_info = user_input return await self.async_step_patient() except LibreLinkAPIAuthenticationError as e: @@ -70,59 +73,62 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required( - CONF_USERNAME, - default=(user_input or {}).get(CONF_USERNAME), - ): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.TEXT - ), + CONF_USERNAME, default=(user_input or {}).get(CONF_USERNAME) + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT), ), - vol.Required(CONF_PASSWORD): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD + vol.Required( + CONF_PASSWORD, default=(user_input or {}).get(CONF_PASSWORD) + ): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD), + ), + vol.Required(CONF_URL): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(label=k, value=v) + for k, v in BASE_URL_LIST.items() + ], + mode=SelectSelectorMode.DROPDOWN, ), ), - vol.Required( - COUNTRY, - description="Country", - default=(COUNTRY_LIST[0]), - ): vol.In(COUNTRY_LIST), } ), errors=_errors, ) async def async_step_patient(self, user_input=None): - if user_input and "PATIENT_ID" in user_input: + """Handle a flow to select specific patient.""" + if user_input is not None: + user_input |= self.basic_info + + patient = {patient.id: patient for patient in self.patients}.get( + user_input[CONF_PATIENT_ID] + ) + return self.async_create_entry( - title=user_input[CONF_USERNAME], + title=f"{patient.name} (via {user_input[CONF_USERNAME]})", data=user_input, ) - data = await self.client.async_get_data() - - c1 = SelectSelectorConfig( - options=[ - SelectOptionDict( - value=patient["patientId"], - label=f'{patient['firstName']} {patient["lastName"]}', - ) - for patient in data - ] - ) - c2 = SelectSelectorConfig( - options=[ - SelectOptionDict(value=k, label=u.unit_of_measurement) - for k, u in UNITS_OF_MEASUREMENT.items() - ] - ) - return self.async_show_form( step_id="patient", data_schema=vol.Schema( { - vol.Required("PATIENT_ID"): SelectSelector(c1), - vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector(c2), + vol.Required(CONF_PATIENT_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=patient.id, label=patient.name) + for patient in self.patients + ] + ) + ), + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[ + u.unit_of_measurement for u in UNITS_OF_MEASUREMENT + ] + ) + ), } ), ) diff --git a/custom_components/librelink/const.py b/custom_components/librelink/const.py index b850f2c..7f0515c 100644 --- a/custom_components/librelink/const.py +++ b/custom_components/librelink/const.py @@ -1,30 +1,17 @@ """Constants for librelink.""" from logging import Logger, getLogger +from typing import Final LOGGER: Logger = getLogger(__package__) -NAME = "LibreLink" -DOMAIN = "librelink" -VERSION = "1.2.3" -ATTRIBUTION = "Data provided by https://libreview.com" -LOGIN_URL = "/llu/auth/login" -CONNECTION_URL = "/llu/connections" -COUNTRY = "Country" -COUNTRY_LIST = [ - "Global", - "Arab Emirates", - "Asia Pacific", - "Australia", - "Canada", - "Germany", - "Europe", - "France", - "Japan", - "Russia", - "United States", -] -BASE_URL_LIST = { +NAME: Final = "LibreLink" +DOMAIN: Final = "librelink" +VERSION: Final = "1.2.3" +ATTRIBUTION: Final = "Data provided by https://libreview.com" +LOGIN_URL: Final = "/llu/auth/login" +CONNECTION_URL: Final = "/llu/connections" +BASE_URL_LIST: Final = { "Global": "https://api.libreview.io", "Arab Emirates": "https://api-ae.libreview.io", "Asia Pacific": "https://api-ap.libreview.io", @@ -37,17 +24,17 @@ "Russia": "https://api.libreview.ru", "United States": "https://api-us.libreview.io", } -PRODUCT = "llu.android" -VERSION_APP = "4.7" -GLUCOSE_VALUE_ICON = "mdi:diabetes" -GLUCOSE_TREND_ICON = { +PRODUCT: Final = "llu.android" +VERSION_APP: Final = "4.7" +GLUCOSE_VALUE_ICON: Final = "mdi:diabetes" +GLUCOSE_TREND_ICON: Final = { 1: "mdi:arrow-down-bold-box", 2: "mdi:arrow-bottom-right-bold-box", 3: "mdi:arrow-right-bold-box", 4: "mdi:arrow-top-right-bold-box", 5: "mdi:arrow-up-bold-box", } -GLUCOSE_TREND_MESSAGE = { +GLUCOSE_TREND_MESSAGE: Final = { 1: "Decreasing fast", 2: "Decreasing", 3: "Stable", @@ -55,5 +42,8 @@ 5: "Increasing fast", } -REFRESH_RATE_MIN = 1 -API_TIME_OUT_SECONDS = 20 + +CONF_PATIENT_ID: Final = "patient_id" + +REFRESH_RATE_MIN: Final = 1 +API_TIME_OUT_SECONDS: Final = 20 diff --git a/custom_components/librelink/coordinator.py b/custom_components/librelink/coordinator.py index 82b5451..86aba94 100644 --- a/custom_components/librelink/coordinator.py +++ b/custom_components/librelink/coordinator.py @@ -4,26 +4,25 @@ from datetime import timedelta -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .api import LibreLinkAPI +from .api import LibreLinkAPI, Patient from .const import DOMAIN, LOGGER, REFRESH_RATE_MIN -class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator): +class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Patient]]): """Class to manage fetching data from the API. single endpoint.""" - config_entry: ConfigEntry - def __init__( self, hass: HomeAssistant, api: LibreLinkAPI, + patient_id: str, ) -> None: """Initialize.""" self.api: LibreLinkAPI = api + self._tracked_patients: set[str] = {patient_id} super().__init__( hass=hass, @@ -32,6 +31,19 @@ def __init__( update_interval=timedelta(minutes=REFRESH_RATE_MIN), ) + def register_patient(self, patient_id: str) -> None: + """Register a new patient to track.""" + self._tracked_patients.add(patient_id) + + def unregister_patient(self, patient_id: str) -> None: + """Unregister a patient to track.""" + self._tracked_patients.remove(patient_id) + + @property + def tracked_patients(self) -> int: + """Return the number of tracked patients.""" + return len(self._tracked_patients) + async def _async_update_data(self): """Update data via library.""" - return await self.api.async_get_data() + return {patient.id: patient for patient in await self.api.async_get_data()} diff --git a/custom_components/librelink/device.py b/custom_components/librelink/device.py deleted file mode 100644 index 175c740..0000000 --- a/custom_components/librelink/device.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Sensor platform for LibreLink.""" -from __future__ import annotations - -import logging - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ATTRIBUTION, DOMAIN, NAME, VERSION -from .coordinator import LibreLinkDataUpdateCoordinator - -# enable logging -_LOGGER = logging.getLogger(__name__) - - -# This class is called when a device is created. -# A device is created for each patient to regroup patient entities - - -class LibreLinkDevice(CoordinatorEntity): - """LibreLinkEntity class.""" - - _attr_has_entity_name = True - _attr_attribution = ATTRIBUTION - - def __init__( - self, - coordinator: LibreLinkDataUpdateCoordinator, - index: int, - ) -> None: - """Initialize.""" - super().__init__(coordinator, context=index) - - # Creating unique IDs using for the device based on the Librelink patient Id. - # self.patient = self.coordinator.data[index]["firstName"] + " " + self.coordinator.data[index]["lastName"] - # self.patientId = self.coordinator.data[index]["patientId"] - self._attr_unique_id = self.coordinator.data[index]["patientId"] - - _LOGGER.debug( - "entity unique id is %s", - self._attr_unique_id, - ) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data[index]["patientId"])}, - name=self.coordinator.data[index]["firstName"] - + " " - + self.coordinator.data[index]["lastName"], - model=VERSION, - manufacturer=NAME, - ) diff --git a/custom_components/librelink/sensor.py b/custom_components/librelink/sensor.py index 51a53a8..af4000b 100644 --- a/custom_components/librelink/sensor.py +++ b/custom_components/librelink/sensor.py @@ -2,32 +2,31 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta -import logging - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_TREND_MESSAGE, GLUCOSE_VALUE_ICON +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTRIBUTION, + CONF_PATIENT_ID, + DOMAIN, + GLUCOSE_TREND_ICON, + GLUCOSE_TREND_MESSAGE, + GLUCOSE_VALUE_ICON, + NAME, + VERSION, +) from .coordinator import LibreLinkDataUpdateCoordinator -from .device import LibreLinkDevice from .units import UNITS_OF_MEASUREMENT, UnitOfMeasurement -# GVS: Tuto pour ajouter des log -_LOGGER = logging.getLogger(__name__) - -""" Three sensors are declared: - Glucose Value - Glucose Trend - Sensor days and related sensor attributes""" - async def async_setup_entry( hass: HomeAssistant, @@ -35,52 +34,66 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ): """Set up the sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.data[CONF_USERNAME]] # If custom unit of measurement is selectid it is initialized, otherwise MG/DL is used - unit = UNITS_OF_MEASUREMENT[config_entry.data[CONF_UNIT_OF_MEASUREMENT]] + unit = {u.unit_of_measurement: u for u in UNITS_OF_MEASUREMENT}.get( + config_entry.data[CONF_UNIT_OF_MEASUREMENT] + ) + pid = config_entry.data[CONF_PATIENT_ID] # For each patients, new Device base on patients and # using an index as we need to keep the coordinator in the @property to get updates from coordinator # we create an array of entities then create entities. sensors = [ - sensor - for index, _ in enumerate(coordinator.data) - for sensor in [ - MeasurementSensor(coordinator, index, unit), - TrendSensor(coordinator, index), - ApplicationTimestampSensor(coordinator, index), - ExpirationTimestampSensor(coordinator, index), - LastMeasurementTimestampSensor(coordinator, index), - ] + MeasurementSensor(coordinator, pid, unit), + TrendSensor(coordinator, pid), + ApplicationTimestampSensor(coordinator, pid), + ExpirationTimestampSensor(coordinator, pid), + LastMeasurementTimestampSensor(coordinator, pid), ] async_add_entities(sensors) -class LibreLinkSensorBase(LibreLinkDevice): +class LibreLinkSensorBase(CoordinatorEntity[LibreLinkDataUpdateCoordinator]): """LibreLink Sensor base class.""" - def __init__( - self, coordinator: LibreLinkDataUpdateCoordinator, coordinator_data_index: int - ) -> None: + def __init__(self, coordinator: LibreLinkDataUpdateCoordinator, pid: str) -> None: """Initialize the device class.""" - super().__init__(coordinator, coordinator_data_index) + super().__init__(coordinator) - self.coordinator_data_index = coordinator_data_index + self.id = pid - self.patient = f'{self._c_data["firstName"]} {self._c_data["lastName"]}' - self.patientId = self._c_data["patientId"] + @property + def device_info(self): + """Return the device info of the sensor.""" + return DeviceInfo( + identifiers={(DOMAIN, self._data.id)}, + name=self._data.name, + model=VERSION, + manufacturer=NAME, + ) + + @property + def attribution(self): + """Return the attribution for this entity.""" + return ATTRIBUTION @property - def _c_data(self): - return self.coordinator.data[self.coordinator_data_index] + def has_entity_name(self): + """Return if the entity has a name.""" + return True + + @property + def _data(self): + return self.coordinator.data[self.id] @property def unique_id(self): """Return the unique id of the sensor.""" - return f"{self.patientId} {self.name}".replace(" ", "_").lower() + return f"{self._data.id} {self.name}".replace(" ", "_").lower() class LibreLinkSensor(LibreLinkSensorBase, SensorEntity): @@ -103,22 +116,25 @@ def name(self): @property def native_value(self): """Return the native value of the sensor.""" - return GLUCOSE_TREND_MESSAGE[(self._c_data["glucoseMeasurement"]["TrendArrow"])] + return GLUCOSE_TREND_MESSAGE[self._data.measurement.trend] @property def icon(self): """Return the icon for the frontend.""" - return GLUCOSE_TREND_ICON[(self._c_data["glucoseMeasurement"]["TrendArrow"])] + return GLUCOSE_TREND_ICON[self._data.measurement.trend] class MeasurementSensor(TrendSensor, LibreLinkSensor): """Glucose Measurement Sensor class.""" def __init__( - self, coordinator, coordinator_data_index, unit: UnitOfMeasurement + self, + coordinator: LibreLinkDataUpdateCoordinator, + pid: str, + unit: UnitOfMeasurement, ) -> None: """Initialize the sensor class.""" - super().__init__(coordinator, coordinator_data_index) + super().__init__(coordinator, pid) self.unit = unit @property @@ -134,9 +150,7 @@ def name(self): @property def native_value(self): """Return the native value of the sensor.""" - return self.unit.from_mg_per_dl( - self._c_data["glucoseMeasurement"]["ValueInMgPerDl"] - ) + return self.unit.from_mg_per_dl(self._data.measurement.value) @property def suggested_display_precision(self): @@ -146,7 +160,7 @@ def suggested_display_precision(self): @property def unit_of_measurement(self): """Return the unit of measurement of the sensor.""" - return self.unit_of_measurement + return self.unit.unit_of_measurement class TimestampSensor(LibreLinkSensor): @@ -169,24 +183,24 @@ def name(self): @property def available(self): """Return if the sensor data are available.""" - return self._c_data["sensor"]["a"] is not None + return self._data.device.application_timestamp is not None @property def native_value(self): """Return the native value of the sensor.""" - return datetime.fromtimestamp(self._c_data["sensor"]["a"], tz=UTC) + return self._data.device.application_timestamp @property def extra_state_attributes(self): """Return the state attributes of the librelink sensor.""" attrs = { - "patientId": self.patientId, - "Patient": self.patient, + "Patient ID": self._data.id, + "Patient": self._data.name, } if self.available: attrs |= { - "Serial number": f"{self._c_data['sensor']['pt']} {self._c_data['sensor']['sn']}", - "Activation date": ApplicationTimestampSensor.native_value.fget(self), + "Serial number": self._data.device.serial_number, + "Activation date": self._data.device.application_timestamp, } return attrs @@ -203,7 +217,7 @@ def name(self): @property def native_value(self): """Return the native value of the sensor.""" - return super().native_value + timedelta(days=14) + return self._data.device.expiration_timestamp class LastMeasurementTimestampSensor(TimestampSensor): @@ -217,8 +231,4 @@ def name(self): @property def native_value(self): """Return the native value of the sensor.""" - - return datetime.strptime( - self._c_data["glucoseMeasurement"]["FactoryTimestamp"], - "%m/%d/%Y %I:%M:%S %p", - ).replace(tzinfo=UTC) + return self._data.measurement.timestamp diff --git a/custom_components/librelink/units.py b/custom_components/librelink/units.py index 9e54211..396e34d 100644 --- a/custom_components/librelink/units.py +++ b/custom_components/librelink/units.py @@ -9,17 +9,18 @@ class UnitOfMeasurement: unit_of_measurement: str suggested_display_precision: int - from_mg_per_dl: Callable[[float], float] = lambda x: x + from_mg_per_dl: Callable[[float], float] -UNITS_OF_MEASUREMENT = { - "mgdl": UnitOfMeasurement( +UNITS_OF_MEASUREMENT = ( + UnitOfMeasurement( unit_of_measurement="mg/dL", suggested_display_precision=0, + from_mg_per_dl=lambda x: x, ), - "mmoll": UnitOfMeasurement( + UnitOfMeasurement( unit_of_measurement="mmol/L", suggested_display_precision=1, from_mg_per_dl=lambda x: x / 18, ), -} +) From edc127c58254af7ac4ea00f3cf22e5b9f2c5f033 Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Wed, 11 Dec 2024 00:14:02 +0100 Subject: [PATCH 6/7] Auth updates --- custom_components/librelink/api.py | 8 +++++++- custom_components/librelink/const.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/librelink/api.py b/custom_components/librelink/api.py index d65ae01..412da87 100644 --- a/custom_components/librelink/api.py +++ b/custom_components/librelink/api.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from hashlib import sha256 import socket import aiohttp @@ -118,6 +119,7 @@ class LibreLinkAPI: def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None: """Initialize the API client.""" self._token = None + self._account_id = None self._session = session self.base_url = base_url @@ -152,6 +154,7 @@ async def async_login(self, username: str, password: str) -> str: raise LibreLinkAPIAuthenticationError() self._token = response["data"]["authTicket"]["token"] + self._account_id = response["data"]["user"]["id"] async def _call_api( self, @@ -165,7 +168,10 @@ async def _call_api( "version": VERSION_APP, } if authenticated: - headers["Authorization"] = "Bearer " + self._token + headers |= { + 'Authorization': f'Bearer {self._token}', + 'AccountId': sha256(self._account_id.encode()).hexdigest() + } call_method = self._session.post if data else self._session.get try: diff --git a/custom_components/librelink/const.py b/custom_components/librelink/const.py index 7f0515c..6e1bb9e 100644 --- a/custom_components/librelink/const.py +++ b/custom_components/librelink/const.py @@ -25,7 +25,7 @@ "United States": "https://api-us.libreview.io", } PRODUCT: Final = "llu.android" -VERSION_APP: Final = "4.7" +VERSION_APP: Final = "4.12.0" GLUCOSE_VALUE_ICON: Final = "mdi:diabetes" GLUCOSE_TREND_ICON: Final = { 1: "mdi:arrow-down-bold-box", From ec1228767b3a7fc9a4725d56d89c47b52d411d39 Mon Sep 17 00:00:00 2001 From: Kuba Sawulski Date: Wed, 11 Dec 2024 00:42:48 +0100 Subject: [PATCH 7/7] Auth updates --- custom_components/librelink/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/librelink/api.py b/custom_components/librelink/api.py index 412da87..66f67e3 100644 --- a/custom_components/librelink/api.py +++ b/custom_components/librelink/api.py @@ -170,7 +170,7 @@ async def _call_api( if authenticated: headers |= { 'Authorization': f'Bearer {self._token}', - 'AccountId': sha256(self._account_id.encode()).hexdigest() + 'Account-Id': sha256(self._account_id.encode()).hexdigest() } call_method = self._session.post if data else self._session.get