├── hacs.json ├── requirements.txt ├── .github ├── workflows │ ├── hassfest.yaml │ └── validate.yaml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── custom_components └── librelink │ ├── manifest.json │ ├── translations │ ├── fr.json │ ├── en.json │ └── pl.json │ ├── mini-graph-glucose.yml │ ├── units.py │ ├── strings.json │ ├── const.py │ ├── coordinator.py │ ├── binary_sensor.py │ ├── __init__.py │ ├── config_flow.py │ ├── api.py │ └── sensor.py ├── LICENSE.txt ├── CONTRIBUTING.md └── README.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LibreLink", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.7.0 2 | homeassistant==2023.8.0 3 | pip>=21.0,<23.2 4 | ruff==0.0.292 5 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /custom_components/librelink/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "librelink", 3 | "name": "LibreLink", 4 | "codeowners": ["@gillesvs"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/gillesvs/librelink", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/gillesvs/librelink/issues", 10 | "version": "1.2.3" 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /custom_components/librelink/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Enter your mail and password", 6 | "description": "Documentation: https://github.com/gillesvs/librelink", 7 | "data": { 8 | "username": "Mail utilisateur", 9 | "password": "Mot de passe" 10 | } 11 | } 12 | }, 13 | "error": { 14 | "auth": "Username/Password is wrong.", 15 | "connection": "Unable to connect to the server.", 16 | "unknown": "Unknown error occurred." 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/librelink/mini-graph-glucose.yml: -------------------------------------------------------------------------------- 1 | type: custom:mini-graph-card 2 | entities: 3 | - entity: sensor.maxence_van_schendel_glucose_measurement 4 | state_adaptive_color: true 5 | show_icon: true 6 | name: Glycémie 7 | lower_bound: 0 8 | align_icon: left 9 | hours_to_show: 24 10 | points_per_hour: 15 11 | smoothing: false 12 | show: 13 | icon_adaptive_color: true 14 | points: hover 15 | state: true 16 | extrema: true 17 | average: true 18 | color_thresholds: 19 | - value: 0 20 | color: '#0000ff' 21 | - value: 70 22 | color: '#00ff00' 23 | - value: 180 24 | color: '#ffa500' 25 | - value: 250 26 | color: '#ff0000' -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug to help us improve 4 | title: 'Issue identified : ' 5 | labels: '' 6 | assignees: gillesvs 7 | 8 | --- 9 | 10 | ** Which country are you from ** 11 | It helps understanding API endpoints your are using. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **Log file** 17 | Activate debug mode in the integration and attached the log file (if version <= 1.2.0, you might want to erase your credentials): 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /custom_components/librelink/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "LibreLink integration", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Enter your mail and password", 7 | "description": "Documentation: https://github.com/gillesvs/librelink", 8 | "data": { 9 | "username": "Mail", 10 | "password": "Password" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "auth": "Username/Password is wrong.", 16 | "connection": "Unable to connect to the server.", 17 | "unknown": "Unknown error occurred." 18 | }, 19 | "abort": { 20 | "already_configured": "Device is already configured" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /custom_components/librelink/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Wpisz email i hasło do konta LibreLink.", 6 | "description": "Dokumentacja: https://github.com/gillesvs/librelink", 7 | "data": { 8 | "username": "Email", 9 | "password": "Hasło" 10 | } 11 | } 12 | }, 13 | "error": { 14 | "auth": "Podany email lub hasło są niepoprawne.", 15 | "connection": "Nie można połączyć się z serwerem LibreLink.", 16 | "unknown": "Wystąpił nieznany błąd." 17 | }, 18 | "abort": { 19 | "already_configured": "Urządzenie jest już skonfigurowane." 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature request : ' 5 | labels: '' 6 | assignees: gillesvs 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /custom_components/librelink/units.py: -------------------------------------------------------------------------------- 1 | """Units of measurement for LibreLink integration.""" 2 | from collections.abc import Callable 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class UnitOfMeasurement: 8 | """Unit of measurement for LibreLink integration.""" 9 | 10 | unit_of_measurement: str 11 | suggested_display_precision: int 12 | from_mg_per_dl: Callable[[float], float] 13 | 14 | 15 | UNITS_OF_MEASUREMENT = ( 16 | UnitOfMeasurement( 17 | unit_of_measurement="mg/dL", 18 | suggested_display_precision=0, 19 | from_mg_per_dl=lambda x: x, 20 | ), 21 | UnitOfMeasurement( 22 | unit_of_measurement="mmol/L", 23 | suggested_display_precision=1, 24 | from_mg_per_dl=lambda x: x / 18, 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /custom_components/librelink/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "LibreLink integration", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Enter your mail and password", 7 | "description": "Documentation: https://github.com/gillesvs/librelink", 8 | "data": { 9 | "username": "Mail", 10 | "password": "Password", 11 | "Country": "Select your region", 12 | "unit_of_measurement": "Unit for glucose measurement" 13 | } 14 | } 15 | }, 16 | "error": { 17 | "auth": "Username/Password is wrong.", 18 | "connection": "Unable to connect to the server.", 19 | "unknown": "Unknown error occurred." 20 | }, 21 | "abort": { 22 | "already_configured": "Device is already configured" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/librelink/const.py: -------------------------------------------------------------------------------- 1 | """Constants for librelink.""" 2 | 3 | from logging import Logger, getLogger 4 | from typing import Final 5 | 6 | LOGGER: Logger = getLogger(__package__) 7 | 8 | NAME: Final = "LibreLink" 9 | DOMAIN: Final = "librelink" 10 | VERSION: Final = "1.2.3" 11 | ATTRIBUTION: Final = "Data provided by https://libreview.com" 12 | LOGIN_URL: Final = "/llu/auth/login" 13 | CONNECTION_URL: Final = "/llu/connections" 14 | BASE_URL_LIST: Final = { 15 | "Global": "https://api.libreview.io", 16 | "Arab Emirates": "https://api-ae.libreview.io", 17 | "Asia Pacific": "https://api-ap.libreview.io", 18 | "Australia": "https://api-au.libreview.io", 19 | "Canada": "https://api-ca.libreview.io", 20 | "Germany": "https://api-de.libreview.io", 21 | "Europe": "https://api-eu.libreview.io", 22 | "France": "https://api-fr.libreview.io", 23 | "Japan": "https://api-jp.libreview.io", 24 | "Russia": "https://api.libreview.ru", 25 | "United States": "https://api-us.libreview.io", 26 | } 27 | PRODUCT: Final = "llu.android" 28 | VERSION_APP: Final = "4.16.0" 29 | GLUCOSE_VALUE_ICON: Final = "mdi:diabetes" 30 | GLUCOSE_TREND_ICON: Final = { 31 | 1: "mdi:arrow-down-bold-box", 32 | 2: "mdi:arrow-bottom-right-bold-box", 33 | 3: "mdi:arrow-right-bold-box", 34 | 4: "mdi:arrow-top-right-bold-box", 35 | 5: "mdi:arrow-up-bold-box", 36 | } 37 | GLUCOSE_TREND_MESSAGE: Final = { 38 | 1: "Decreasing fast", 39 | 2: "Decreasing", 40 | 3: "Stable", 41 | 4: "Increasing", 42 | 5: "Increasing fast", 43 | } 44 | 45 | 46 | CONF_PATIENT_ID: Final = "patient_id" 47 | 48 | REFRESH_RATE_MIN: Final = 1 49 | API_TIME_OUT_SECONDS: Final = 20 50 | -------------------------------------------------------------------------------- /custom_components/librelink/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 9 | 10 | from .api import LibreLinkAPI, Patient 11 | from .const import DOMAIN, LOGGER, REFRESH_RATE_MIN 12 | 13 | 14 | class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Patient]]): 15 | """Class to manage fetching data from the API. single endpoint.""" 16 | 17 | def __init__( 18 | self, 19 | hass: HomeAssistant, 20 | api: LibreLinkAPI, 21 | patient_id: str, 22 | ) -> None: 23 | """Initialize.""" 24 | self.api: LibreLinkAPI = api 25 | self._tracked_patients: set[str] = {patient_id} 26 | 27 | super().__init__( 28 | hass=hass, 29 | logger=LOGGER, 30 | name=DOMAIN, 31 | update_interval=timedelta(minutes=REFRESH_RATE_MIN), 32 | ) 33 | 34 | def register_patient(self, patient_id: str) -> None: 35 | """Register a new patient to track.""" 36 | self._tracked_patients.add(patient_id) 37 | 38 | def unregister_patient(self, patient_id: str) -> None: 39 | """Unregister a patient to track.""" 40 | self._tracked_patients.remove(patient_id) 41 | 42 | @property 43 | def tracked_patients(self) -> int: 44 | """Return the number of tracked patients.""" 45 | return len(self._tracked_patients) 46 | 47 | async def _async_update_data(self): 48 | """Update data via library.""" 49 | return {patient.id: patient for patient in await self.api.async_get_data()} 50 | -------------------------------------------------------------------------------- /custom_components/librelink/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for librelink.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | BinarySensorDeviceClass, 7 | BinarySensorEntity, 8 | ) 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import CONF_USERNAME 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from .const import CONF_PATIENT_ID, DOMAIN 15 | from .coordinator import LibreLinkDataUpdateCoordinator 16 | from .sensor import LibreLinkSensorBase 17 | 18 | 19 | async def async_setup_entry( 20 | hass: HomeAssistant, 21 | config_entry: ConfigEntry, 22 | async_add_entities: AddEntitiesCallback, 23 | ): 24 | """Set up the binary_sensor platform.""" 25 | 26 | coordinator: LibreLinkDataUpdateCoordinator = hass.data[DOMAIN][ 27 | config_entry.data[CONF_USERNAME] 28 | ] 29 | 30 | pid = config_entry.data[CONF_PATIENT_ID] 31 | 32 | sensors = [ 33 | HighSensor(coordinator, pid), 34 | LowSensor(coordinator, pid), 35 | ] 36 | async_add_entities(sensors) 37 | 38 | 39 | class LibreLinkBinarySensor(LibreLinkSensorBase, BinarySensorEntity): 40 | """LibreLink Binary Sensor class.""" 41 | 42 | @property 43 | def device_class(self) -> str: 44 | """Return the class of this device.""" 45 | return BinarySensorDeviceClass.SAFETY 46 | 47 | 48 | class HighSensor(LibreLinkBinarySensor): 49 | """High Sensor class.""" 50 | 51 | @property 52 | def name(self) -> str: 53 | """Return the name of the binary_sensor.""" 54 | return "Is High" 55 | 56 | @property 57 | def is_on(self) -> bool: 58 | """Return true if the binary_sensor is on.""" 59 | return self._data.measurement.value >= self._data.target.high 60 | 61 | 62 | class LowSensor(LibreLinkBinarySensor): 63 | """Low Sensor class.""" 64 | 65 | @property 66 | def name(self) -> str: 67 | """Return the name of the binary_sensor.""" 68 | return "Is Low" 69 | 70 | @property 71 | def is_on(self) -> bool: 72 | """Return true if the binary_sensor is on.""" 73 | return self._data.measurement.value <= self._data.target.low 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/Naereen/StrapDown.js/graphs/commit-activity) 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 3 | [![Validate with Hassfest](https://github.com/gillesvs/librelink/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/gillesvs/librelink/actions/workflows/hassfest.yaml) 4 | [![Validate with HACS](https://github.com/gillesvs/librelink/actions/workflows/validate.yaml/badge.svg)](https://github.com/gillesvs/librelink/actions/workflows/validate.yaml) 5 | 6 | # LibrelinkUp Integration for Home Assistant 7 | 8 | 9 | [integration_librelink]: https://github.com/gillesvs/librelink.git 10 | [buymecoffee]: https://www.buymeacoffee.com/gillesvs 11 | 12 | **This integration will set up the following platforms for each patient linked to the librelinkUp account.** 13 | 14 | Platform | Description 15 | -- | -- 16 | 17 | `sensor` | Show info from LibrelinkUp API. 18 | - Active Sensor (in days) : All information about your sensor. State is number of days since activation. 19 | - Glucose Measurement (in mg/dL) : Measured value every minute. 20 | - Glucose Trend : in plain text + icon. 21 | - Minutes since update (in min) : self explanatory. 22 | 23 | `binary_sensor` | to measure high and low. 24 | - Is High | True of False. 25 | - Is Low | True of False. 26 | 27 | ## Illustration with a custom:mini-graph-card 28 | 29 | ![image](https://github.com/gillesvs/librelink/assets/51242147/bfed1b2b-dbf7-4666-a202-885ff3db67b8) 30 | 31 | And the yaml if you like this card: 32 | https://github.com/gillesvs/librelink/blob/main/custom_components/librelink/mini-graph-glucose.yml 33 | 34 | 35 | ## Installation 36 | 37 | 1. Add this repository URL as a custom repository in HACS 38 | 2. Restart Home Assistant 39 | 3. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Librelink" 40 | 41 | ## Configuration is done in the UI 42 | 43 | You need a librelinkUp account to use this integration 44 | User must have accepted Abbott user agreement in the librelinkUp app for the integration to work. 45 | 46 | - Use username (mail) and password of the librelinkUp account. 47 | - A token will be retreived for the duration of the HA session. 48 | 49 | 50 | ## Contributions are welcome! 51 | 52 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 53 | 54 | *** 55 | 56 | Buy Me A Coffee 57 | -------------------------------------------------------------------------------- /custom_components/librelink/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom integration to integrate LibreLink with Home Assistant.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 9 | 10 | from .api import LibreLinkAPI 11 | from .const import CONF_PATIENT_ID, DOMAIN, LOGGER 12 | from .coordinator import LibreLinkDataUpdateCoordinator 13 | 14 | PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] 15 | 16 | 17 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 18 | """Set up this integration using UI.""" 19 | 20 | LOGGER.debug( 21 | "Appel de async_setup_entry entry: entry_id= %s, data= %s", 22 | entry.entry_id, 23 | entry.data, 24 | ) 25 | 26 | username = entry.data[CONF_USERNAME] 27 | password = entry.data[CONF_PASSWORD] 28 | base_url = entry.data[CONF_URL] 29 | patient_id = entry.data[CONF_PATIENT_ID] 30 | 31 | domain_data = hass.data.setdefault(DOMAIN, {}) 32 | 33 | if username not in domain_data: 34 | # Using the declared API for login based on patient credentials to retreive the bearer Token 35 | api = LibreLinkAPI( 36 | base_url=base_url, 37 | session=async_get_clientsession(hass), 38 | ) 39 | 40 | # Then getting the token. 41 | await api.async_login(username=username, password=password) 42 | 43 | coordinator = LibreLinkDataUpdateCoordinator( 44 | hass=hass, api=api, patient_id=patient_id 45 | ) 46 | 47 | # First poll of the data to be ready for entities initialization 48 | await coordinator.async_config_entry_first_refresh() 49 | 50 | domain_data[username] = coordinator 51 | else: 52 | coordinator: LibreLinkDataUpdateCoordinator = domain_data[username] 53 | coordinator.register_patient(patient_id) 54 | 55 | # Then launch async_setup_entry for our declared entities in sensor.py and binary_sensor.py 56 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 57 | 58 | return True 59 | 60 | 61 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 62 | """Handle removal of an entry.""" 63 | if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 64 | coordinator: LibreLinkDataUpdateCoordinator = hass.data[DOMAIN][CONF_USERNAME] 65 | coordinator.unregister_patient(entry.data[CONF_PATIENT_ID]) 66 | if coordinator.tracked_patients == 0: 67 | hass.data[DOMAIN].pop(CONF_USERNAME) 68 | return unloaded 69 | -------------------------------------------------------------------------------- /custom_components/librelink/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | import voluptuous as vol 6 | 7 | from homeassistant import config_entries 8 | from homeassistant.const import ( 9 | CONF_PASSWORD, 10 | CONF_UNIT_OF_MEASUREMENT, 11 | CONF_URL, 12 | CONF_USERNAME, 13 | ) 14 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 15 | from homeassistant.helpers.selector import ( 16 | SelectOptionDict, 17 | SelectSelector, 18 | SelectSelectorConfig, 19 | SelectSelectorMode, 20 | TextSelector, 21 | TextSelectorConfig, 22 | TextSelectorType, 23 | ) 24 | 25 | from .api import ( 26 | LibreLinkAPI, 27 | LibreLinkAPIAuthenticationError, 28 | LibreLinkAPIConnectionError, 29 | LibreLinkAPIError, 30 | ) 31 | from .const import BASE_URL_LIST, CONF_PATIENT_ID, DOMAIN, LOGGER 32 | from .units import UNITS_OF_MEASUREMENT 33 | 34 | 35 | class LibreLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 36 | """Config flow for LibreLink.""" 37 | 38 | VERSION = 1 39 | 40 | async def async_step_user( 41 | self, 42 | user_input: dict | None = None, 43 | ) -> config_entries.FlowResult: 44 | """Handle a flow initialized by the user.""" 45 | _errors = {} 46 | if user_input is not None: 47 | try: 48 | username = user_input[CONF_USERNAME] 49 | password = user_input[CONF_PASSWORD] 50 | base_url = user_input[CONF_URL] 51 | 52 | client = LibreLinkAPI( 53 | base_url=base_url, session=async_create_clientsession(self.hass) 54 | ) 55 | await client.async_login(username, password) 56 | 57 | self.patients = await client.async_get_data() 58 | self.basic_info = user_input 59 | 60 | return await self.async_step_patient() 61 | except LibreLinkAPIAuthenticationError as e: 62 | LOGGER.warning(e) 63 | _errors["base"] = "auth" 64 | except LibreLinkAPIConnectionError as e: 65 | LOGGER.error(e) 66 | _errors["base"] = "connection" 67 | except LibreLinkAPIError as e: 68 | LOGGER.exception(e) 69 | _errors["base"] = "unknown" 70 | 71 | return self.async_show_form( 72 | step_id="user", 73 | data_schema=vol.Schema( 74 | { 75 | vol.Required( 76 | CONF_USERNAME, default=(user_input or {}).get(CONF_USERNAME) 77 | ): TextSelector( 78 | TextSelectorConfig(type=TextSelectorType.TEXT), 79 | ), 80 | vol.Required( 81 | CONF_PASSWORD, default=(user_input or {}).get(CONF_PASSWORD) 82 | ): TextSelector( 83 | TextSelectorConfig(type=TextSelectorType.PASSWORD), 84 | ), 85 | vol.Required(CONF_URL): SelectSelector( 86 | SelectSelectorConfig( 87 | options=[ 88 | SelectOptionDict(label=k, value=v) 89 | for k, v in BASE_URL_LIST.items() 90 | ], 91 | mode=SelectSelectorMode.DROPDOWN, 92 | ), 93 | ), 94 | } 95 | ), 96 | errors=_errors, 97 | ) 98 | 99 | async def async_step_patient(self, user_input=None): 100 | """Handle a flow to select specific patient.""" 101 | if user_input is not None: 102 | user_input |= self.basic_info 103 | 104 | patient = {patient.id: patient for patient in self.patients}.get( 105 | user_input[CONF_PATIENT_ID] 106 | ) 107 | 108 | return self.async_create_entry( 109 | title=f"{patient.name} (via {user_input[CONF_USERNAME]})", 110 | data=user_input, 111 | ) 112 | 113 | return self.async_show_form( 114 | step_id="patient", 115 | data_schema=vol.Schema( 116 | { 117 | vol.Required(CONF_PATIENT_ID): SelectSelector( 118 | SelectSelectorConfig( 119 | options=[ 120 | SelectOptionDict(value=patient.id, label=patient.name) 121 | for patient in self.patients 122 | ] 123 | ) 124 | ), 125 | vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( 126 | SelectSelectorConfig( 127 | options=[ 128 | u.unit_of_measurement for u in UNITS_OF_MEASUREMENT 129 | ] 130 | ) 131 | ), 132 | } 133 | ), 134 | ) 135 | -------------------------------------------------------------------------------- /custom_components/librelink/api.py: -------------------------------------------------------------------------------- 1 | """I used the https://libreview-unofficial.stoplight.io/docs/libreview-unofficial/ as a starting point to use the Abbot Libreview API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from datetime import UTC, datetime, timedelta 7 | from hashlib import sha256 8 | import socket 9 | 10 | import aiohttp 11 | 12 | from .const import ( 13 | API_TIME_OUT_SECONDS, 14 | CONNECTION_URL, 15 | LOGGER, 16 | LOGIN_URL, 17 | PRODUCT, 18 | VERSION_APP, 19 | ) 20 | 21 | 22 | @dataclass 23 | class Target: 24 | """Target Glucose data.""" 25 | 26 | high: int 27 | low: int 28 | 29 | 30 | @dataclass 31 | class Measurement: 32 | """Measurement data.""" 33 | 34 | value: int 35 | timestamp: datetime 36 | trend: int 37 | 38 | 39 | @dataclass 40 | class LibreLinkDevice: 41 | """LibreLink device data.""" 42 | 43 | serial_number: str 44 | application_timestamp: datetime | None 45 | 46 | @property 47 | def expiration_timestamp(self): 48 | """Return the expiration timestamp of the sensor.""" 49 | return self.application_timestamp + timedelta(days=14) 50 | 51 | 52 | @dataclass 53 | class Patient: 54 | """Patient data.""" 55 | 56 | id: str 57 | first_name: str 58 | last_name: str 59 | measurement: Measurement 60 | target: Target 61 | device: LibreLinkDevice 62 | 63 | @property 64 | def name(self): 65 | """Return the full name of the patient.""" 66 | return f"{self.first_name} {self.last_name}" 67 | 68 | @classmethod 69 | def from_api_response_data(cls, data): 70 | """Create a Patient object from the API response data.""" 71 | return cls( 72 | id=data["patientId"], 73 | first_name=data["firstName"], 74 | last_name=data["lastName"], 75 | measurement=Measurement( 76 | value=data["glucoseMeasurement"]["ValueInMgPerDl"], 77 | timestamp=datetime.strptime( 78 | data["glucoseMeasurement"]["FactoryTimestamp"], 79 | "%m/%d/%Y %I:%M:%S %p", 80 | ).replace(tzinfo=UTC), 81 | trend=data["glucoseMeasurement"]["TrendArrow"], 82 | ), 83 | target=Target( 84 | high=data["targetHigh"], 85 | low=data["targetLow"], 86 | ), 87 | device=LibreLinkDevice( 88 | serial_number=f'{data["sensor"]["pt"]}{data['sensor']['sn']}', 89 | application_timestamp=datetime.fromtimestamp( 90 | data["sensor"]["a"], tz=UTC 91 | ), 92 | ), 93 | ) 94 | 95 | 96 | class LibreLinkAPIError(Exception): 97 | """Base class for exceptions in this module.""" 98 | 99 | 100 | class LibreLinkAPIAuthenticationError(LibreLinkAPIError): 101 | """Exception raised when the API authentication fails.""" 102 | 103 | def __init__(self) -> None: 104 | """Initialize the API error.""" 105 | super().__init__("Invalid credentials") 106 | 107 | 108 | class LibreLinkAPIConnectionError(LibreLinkAPIError): 109 | """Exception raised when the API connection fails.""" 110 | 111 | def __init__(self, message: str = None) -> None: 112 | """Initialize the API error.""" 113 | super().__init__(message or "Connection error") 114 | 115 | 116 | class LibreLinkAPI: 117 | """API class for communication with the LibreLink API.""" 118 | 119 | def __init__(self, base_url: str, session: aiohttp.ClientSession) -> None: 120 | """Initialize the API client.""" 121 | self._token = None 122 | self._account_id = None 123 | self._session = session 124 | self.base_url = base_url 125 | 126 | async def async_get_data(self): 127 | """Get data from the API.""" 128 | response = await self._call_api(url=CONNECTION_URL) 129 | LOGGER.debug("Return API Status:%s ", response["status"]) 130 | # API status return 0 if everything goes well. 131 | if response["status"] != 0: 132 | raise LibreLinkAPIConnectionError() 133 | 134 | patients = [ 135 | Patient.from_api_response_data(patient) for patient in response["data"] 136 | ] 137 | 138 | LOGGER.debug( 139 | "Number of patients : %s and patient list %s", len(patients), patients 140 | ) 141 | self._token = response["ticket"]["token"] 142 | 143 | return patients 144 | 145 | async def async_login(self, username: str, password: str) -> str: 146 | """Get token from the API.""" 147 | response = await self._call_api( 148 | url=LOGIN_URL, 149 | data={"email": username, "password": password}, 150 | authenticated=False, 151 | ) 152 | LOGGER.debug("Login status : %s", response["status"]) 153 | if response["status"] == 2: 154 | raise LibreLinkAPIAuthenticationError() 155 | 156 | self._token = response["data"]["authTicket"]["token"] 157 | self._account_id = response["data"]["user"]["id"] 158 | 159 | async def _call_api( 160 | self, 161 | url: str, 162 | data: dict | None = None, 163 | authenticated: bool = True, 164 | ) -> any: 165 | """Get information from the API.""" 166 | headers = { 167 | "product": PRODUCT, 168 | "version": VERSION_APP, 169 | } 170 | if authenticated: 171 | headers |= { 172 | 'Authorization': f'Bearer {self._token}', 173 | 'Account-Id': sha256(self._account_id.encode()).hexdigest() 174 | } 175 | 176 | call_method = self._session.post if data else self._session.get 177 | try: 178 | response = await call_method( 179 | url=self.base_url + url, 180 | headers=headers, 181 | json=data, 182 | timeout=aiohttp.ClientTimeout(total=API_TIME_OUT_SECONDS), 183 | ) 184 | LOGGER.debug("response.status: %s", response.status) 185 | if response.status in (401, 403): 186 | raise LibreLinkAPIAuthenticationError() 187 | response.raise_for_status() 188 | return await response.json() 189 | except TimeoutError as e: 190 | raise LibreLinkAPIConnectionError("Timeout Error") from e 191 | except (aiohttp.ClientError, socket.gaierror) as e: 192 | raise LibreLinkAPIConnectionError() from e 193 | except Exception as e: 194 | raise LibreLinkAPIError() from e 195 | -------------------------------------------------------------------------------- /custom_components/librelink/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorEntity, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from .const import ( 18 | ATTRIBUTION, 19 | CONF_PATIENT_ID, 20 | DOMAIN, 21 | GLUCOSE_TREND_ICON, 22 | GLUCOSE_TREND_MESSAGE, 23 | GLUCOSE_VALUE_ICON, 24 | NAME, 25 | VERSION, 26 | ) 27 | from .coordinator import LibreLinkDataUpdateCoordinator 28 | from .units import UNITS_OF_MEASUREMENT, UnitOfMeasurement 29 | 30 | 31 | async def async_setup_entry( 32 | hass: HomeAssistant, 33 | config_entry: ConfigEntry, 34 | async_add_entities: AddEntitiesCallback, 35 | ): 36 | """Set up the sensor platform.""" 37 | coordinator = hass.data[DOMAIN][config_entry.data[CONF_USERNAME]] 38 | 39 | # If custom unit of measurement is selectid it is initialized, otherwise MG/DL is used 40 | unit = {u.unit_of_measurement: u for u in UNITS_OF_MEASUREMENT}.get( 41 | config_entry.data[CONF_UNIT_OF_MEASUREMENT] 42 | ) 43 | pid = config_entry.data[CONF_PATIENT_ID] 44 | 45 | # For each patients, new Device base on patients and 46 | # using an index as we need to keep the coordinator in the @property to get updates from coordinator 47 | # we create an array of entities then create entities. 48 | 49 | sensors = [ 50 | MeasurementSensor(coordinator, pid, unit), 51 | TrendSensor(coordinator, pid), 52 | ApplicationTimestampSensor(coordinator, pid), 53 | ExpirationTimestampSensor(coordinator, pid), 54 | LastMeasurementTimestampSensor(coordinator, pid), 55 | ] 56 | 57 | async_add_entities(sensors) 58 | 59 | 60 | class LibreLinkSensorBase(CoordinatorEntity[LibreLinkDataUpdateCoordinator]): 61 | """LibreLink Sensor base class.""" 62 | 63 | def __init__(self, coordinator: LibreLinkDataUpdateCoordinator, pid: str) -> None: 64 | """Initialize the device class.""" 65 | super().__init__(coordinator) 66 | 67 | self.id = pid 68 | 69 | @property 70 | def device_info(self): 71 | """Return the device info of the sensor.""" 72 | return DeviceInfo( 73 | identifiers={(DOMAIN, self._data.id)}, 74 | name=self._data.name, 75 | model=VERSION, 76 | manufacturer=NAME, 77 | ) 78 | 79 | @property 80 | def attribution(self): 81 | """Return the attribution for this entity.""" 82 | return ATTRIBUTION 83 | 84 | @property 85 | def has_entity_name(self): 86 | """Return if the entity has a name.""" 87 | return True 88 | 89 | @property 90 | def _data(self): 91 | return self.coordinator.data[self.id] 92 | 93 | @property 94 | def unique_id(self): 95 | """Return the unique id of the sensor.""" 96 | return f"{self._data.id} {self.name}".replace(" ", "_").lower() 97 | 98 | 99 | class LibreLinkSensor(LibreLinkSensorBase, SensorEntity): 100 | """LibreLink Sensor class.""" 101 | 102 | @property 103 | def icon(self): 104 | """Return the icon for the frontend.""" 105 | return GLUCOSE_VALUE_ICON 106 | 107 | 108 | class TrendSensor(LibreLinkSensor): 109 | """Glucose Trend Sensor class.""" 110 | 111 | @property 112 | def name(self): 113 | """Return the name of the sensor.""" 114 | return "Trend" 115 | 116 | @property 117 | def native_value(self): 118 | """Return the native value of the sensor.""" 119 | return GLUCOSE_TREND_MESSAGE[self._data.measurement.trend] 120 | 121 | @property 122 | def icon(self): 123 | """Return the icon for the frontend.""" 124 | return GLUCOSE_TREND_ICON[self._data.measurement.trend] 125 | 126 | 127 | class MeasurementSensor(TrendSensor, LibreLinkSensor): 128 | """Glucose Measurement Sensor class.""" 129 | 130 | def __init__( 131 | self, 132 | coordinator: LibreLinkDataUpdateCoordinator, 133 | pid: str, 134 | unit: UnitOfMeasurement, 135 | ) -> None: 136 | """Initialize the sensor class.""" 137 | super().__init__(coordinator, pid) 138 | self.unit = unit 139 | 140 | @property 141 | def state_class(self): 142 | """Return the state class of the sensor.""" 143 | return SensorStateClass.MEASUREMENT 144 | 145 | @property 146 | def name(self): 147 | """Return the name of the sensor.""" 148 | return "Measurement" 149 | 150 | @property 151 | def native_value(self): 152 | """Return the native value of the sensor.""" 153 | return self.unit.from_mg_per_dl(self._data.measurement.value) 154 | 155 | @property 156 | def suggested_display_precision(self): 157 | """Return the suggested precision of the sensor.""" 158 | return self.unit.suggested_display_precision 159 | 160 | @property 161 | def unit_of_measurement(self): 162 | """Return the unit of measurement of the sensor.""" 163 | return self.unit.unit_of_measurement 164 | 165 | 166 | class TimestampSensor(LibreLinkSensor): 167 | """Timestamp Sensor class.""" 168 | 169 | @property 170 | def device_class(self): 171 | """Return the device class of the sensor.""" 172 | return SensorDeviceClass.TIMESTAMP 173 | 174 | 175 | class ApplicationTimestampSensor(TimestampSensor): 176 | """Sensor Days Sensor class.""" 177 | 178 | @property 179 | def name(self): 180 | """Return the name of the sensor.""" 181 | return "Application Timestamp" 182 | 183 | @property 184 | def available(self): 185 | """Return if the sensor data are available.""" 186 | return self._data.device.application_timestamp is not None 187 | 188 | @property 189 | def native_value(self): 190 | """Return the native value of the sensor.""" 191 | return self._data.device.application_timestamp 192 | 193 | @property 194 | def extra_state_attributes(self): 195 | """Return the state attributes of the librelink sensor.""" 196 | attrs = { 197 | "Patient ID": self._data.id, 198 | "Patient": self._data.name, 199 | } 200 | if self.available: 201 | attrs |= { 202 | "Serial number": self._data.device.serial_number, 203 | "Activation date": self._data.device.application_timestamp, 204 | } 205 | 206 | return attrs 207 | 208 | 209 | class ExpirationTimestampSensor(ApplicationTimestampSensor): 210 | """Sensor Days Sensor class.""" 211 | 212 | @property 213 | def name(self): 214 | """Return the name of the sensor.""" 215 | return "Expiration Timestamp" 216 | 217 | @property 218 | def native_value(self): 219 | """Return the native value of the sensor.""" 220 | return self._data.device.expiration_timestamp 221 | 222 | 223 | class LastMeasurementTimestampSensor(TimestampSensor): 224 | """Sensor Delay Sensor class.""" 225 | 226 | @property 227 | def name(self): 228 | """Return the name of the sensor.""" 229 | return "Last Measurement Timestamp" 230 | 231 | @property 232 | def native_value(self): 233 | """Return the native value of the sensor.""" 234 | return self._data.measurement.timestamp 235 | --------------------------------------------------------------------------------