├── 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 │ ├── mini-graph-glucose.yml │ ├── strings.json │ ├── const.py │ ├── coordinator.py │ ├── device.py │ ├── binary_sensor.py │ ├── __init__.py │ ├── config_flow.py │ ├── sensor.py │ └── api.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 | -------------------------------------------------------------------------------- /.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/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 | 5 | LOGGER: Logger = getLogger(__package__) 6 | 7 | NAME = "LibreLink" 8 | DOMAIN = "librelink" 9 | VERSION = "1.2.3" 10 | ATTRIBUTION = "Data provided by https://libreview.com" 11 | LOGIN_URL = "/llu/auth/login" 12 | CONNECTION_URL = "/llu/connections" 13 | COUNTRY = "Country" 14 | COUNTRY_LIST = [ 15 | "Global", 16 | "Arab Emirates", 17 | "Asia Pacific", 18 | "Australia", 19 | "Canada", 20 | "Germany", 21 | "Europe", 22 | "France", 23 | "Japan", 24 | "Russia", 25 | "United States", 26 | ] 27 | BASE_URL_LIST = { 28 | "Global": "https://api.libreview.io", 29 | "Arab Emirates": "https://api-ae.libreview.io", 30 | "Asia Pacific": "https://api-ap.libreview.io", 31 | "Australia": "https://api-au.libreview.io", 32 | "Canada": "https://api-ca.libreview.io", 33 | "Germany": "https://api-de.libreview.io", 34 | "Europe": "https://api-eu.libreview.io", 35 | "France": "https://api-fr.libreview.io", 36 | "Japan": "https://api-jp.libreview.io", 37 | "Russia": "https://api.libreview.ru", 38 | "United States": "https://api-us.libreview.io", 39 | } 40 | PRODUCT = "llu.android" 41 | VERSION_APP = "4.16.0" 42 | APPLICATION = "application/json" 43 | GLUCOSE_VALUE_ICON = "mdi:diabetes" 44 | GLUCOSE_TREND_ICON = [ 45 | "mdi:arrow-down-bold-box", 46 | "mdi:arrow-bottom-right-bold-box", 47 | "mdi:arrow-right-bold-box", 48 | "mdi:arrow-top-right-bold-box", 49 | "mdi:arrow-up-bold-box", 50 | ] 51 | GLUCOSE_TREND_MESSAGE = [ 52 | "Decreasing fast", 53 | "Decreasing", 54 | "Stable", 55 | "Increasing", 56 | "Increasing fast", 57 | ] 58 | MMOL_L = "mmol/L" 59 | MG_DL = "mg/dL" 60 | MMOL_DL_TO_MG_DL = 18 61 | REFRESH_RATE_MIN = 1 62 | API_TIME_OUT_SECONDS = 20 63 | -------------------------------------------------------------------------------- /custom_components/librelink/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | import logging 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.exceptions import ConfigEntryAuthFailed 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 12 | 13 | from .api import LibreLinkApiAuthenticationError, LibreLinkApiClient, LibreLinkApiError 14 | from .const import DOMAIN, LOGGER, REFRESH_RATE_MIN 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class LibreLinkDataUpdateCoordinator(DataUpdateCoordinator): 20 | """Class to manage fetching data from the API. single endpoint.""" 21 | 22 | config_entry: ConfigEntry 23 | 24 | def __init__( 25 | self, 26 | hass: HomeAssistant, 27 | client: LibreLinkApiClient, 28 | ) -> None: 29 | """Initialize.""" 30 | self.client = client 31 | self.api: LibreLinkApiClient = client 32 | 33 | super().__init__( 34 | hass=hass, 35 | logger=LOGGER, 36 | name=DOMAIN, 37 | update_interval=timedelta(minutes=REFRESH_RATE_MIN), 38 | ) 39 | 40 | async def _async_update_data(self): 41 | """Update data via library.""" 42 | try: 43 | return await self.client.async_get_data() 44 | except LibreLinkApiAuthenticationError as exception: 45 | _LOGGER.debug("Exception: authentication error during coordinator update") 46 | raise ConfigEntryAuthFailed(exception) from exception 47 | except LibreLinkApiError as exception: 48 | _LOGGER.debug("Exception: general API error during coordinator update") 49 | raise UpdateFailed(exception) from exception 50 | -------------------------------------------------------------------------------- /custom_components/librelink/device.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for LibreLink.""" 2 | from __future__ import annotations 3 | 4 | from homeassistant.helpers.device_registry import DeviceInfo 5 | 6 | from .const import ATTRIBUTION, DOMAIN, NAME, VERSION 7 | from .coordinator import LibreLinkDataUpdateCoordinator 8 | 9 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 10 | 11 | from .coordinator import LibreLinkDataUpdateCoordinator 12 | 13 | import logging 14 | 15 | # enable logging 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | # This class is called when a device is created. 20 | # A device is created for each patient to regroup patient entities 21 | 22 | class LibreLinkDevice(CoordinatorEntity): 23 | """LibreLinkEntity class.""" 24 | 25 | _attr_has_entity_name = True 26 | _attr_attribution = ATTRIBUTION 27 | 28 | def __init__( 29 | self, 30 | coordinator: LibreLinkDataUpdateCoordinator, 31 | index: int, 32 | ) -> None: 33 | """Initialize.""" 34 | super().__init__(coordinator, context=index) 35 | 36 | # Creating unique IDs using for the device based on the Librelink patient Id. 37 | # self.patient = self.coordinator.data[index]["firstName"] + " " + self.coordinator.data[index]["lastName"] 38 | # self.patientId = self.coordinator.data[index]["patientId"] 39 | self._attr_unique_id = self.coordinator.data[index]["patientId"] 40 | 41 | _LOGGER.debug( 42 | "entity unique id is %s", 43 | self._attr_unique_id, 44 | ) 45 | self._attr_device_info = DeviceInfo( 46 | identifiers={(DOMAIN, self.coordinator.data[index]["patientId"])}, 47 | name=self.coordinator.data[index]["firstName"] + " " + self.coordinator.data[index]["lastName"], 48 | model=VERSION, 49 | manufacturer=NAME, 50 | ) 51 | 52 | -------------------------------------------------------------------------------- /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-no-red.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/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for librelink.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.binary_sensor import BinarySensorEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from .const import DOMAIN 13 | from .coordinator import LibreLinkDataUpdateCoordinator 14 | from .device import LibreLinkDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 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 = hass.data[DOMAIN][config_entry.entry_id] 27 | 28 | # to manage multiple patients, the API return an array of patients in "data". So we loop in the array 29 | # and create as many devices and sensors as we do have patients. 30 | sensors = [] 31 | # Loop through list of patients which are under "Data" 32 | for index, _ in enumerate(coordinator.data): 33 | sensors.extend( 34 | [ 35 | LibreLinkBinarySensor( 36 | coordinator, 37 | index, 38 | key="isHigh", 39 | name="Is High", 40 | ), 41 | LibreLinkBinarySensor( 42 | coordinator, 43 | index, 44 | key="isLow", 45 | name="Is Low", 46 | ), 47 | ] 48 | ) 49 | async_add_entities(sensors) 50 | 51 | 52 | class LibreLinkBinarySensor(LibreLinkDevice, BinarySensorEntity): 53 | """librelink binary_sensor class.""" 54 | 55 | def __init__( 56 | self, 57 | coordinator: LibreLinkDataUpdateCoordinator, 58 | index: int, 59 | key: str, 60 | name: str, 61 | ) -> None: 62 | """Initialize the device class.""" 63 | super().__init__(coordinator, index) 64 | 65 | self.key = key 66 | self.patients = ( 67 | coordinator.data[index]["firstName"] 68 | + " " 69 | + coordinator.data[index]["lastName"] 70 | ) 71 | self.patientId = self.coordinator.data[index]["patientId"] 72 | self.index = index 73 | self._attr_name = name 74 | self.coordinator = coordinator 75 | 76 | # define unique_id based on patient id and sensor key 77 | @property 78 | def unique_id(self) -> str: 79 | """Return a unique id for the sensor.""" 80 | return f"{self.coordinator.data[self.index]['patientId']}_{self.key}" 81 | 82 | # define state based on the entity_description key 83 | @property 84 | def is_on(self) -> bool: 85 | """Return true if the binary_sensor is on.""" 86 | return self.coordinator.data[self.index]["glucoseMeasurement"][self.key] 87 | -------------------------------------------------------------------------------- /custom_components/librelink/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom integration to integrate LibreLink with Home Assistant.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | 12 | from .api import LibreLinkApiClient, LibreLinkApiLogin, LibreLinkGetGraph 13 | from .const import BASE_URL_LIST, COUNTRY, DOMAIN 14 | from .coordinator import LibreLinkDataUpdateCoordinator 15 | 16 | PLATFORMS: list[Platform] = [ 17 | Platform.SENSOR, 18 | Platform.BINARY_SENSOR, 19 | ] 20 | 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 26 | """Set up this integration using UI.""" 27 | 28 | _LOGGER.debug( 29 | "Appel de async_setup_entry entry: entry_id= %s, data= %s, user = %s BaseUrl = %s", 30 | entry.entry_id, 31 | entry.data, 32 | entry.data[CONF_USERNAME], 33 | # entry.data[CONF_PASSWORD], 34 | BASE_URL_LIST.get(entry.data[COUNTRY]), 35 | ) 36 | hass.data.setdefault(DOMAIN, {}) 37 | 38 | # Using the declared API for login based on patient credentials to retreive the bearer Token 39 | 40 | myLibrelinkLogin = LibreLinkApiLogin( 41 | username=entry.data[CONF_USERNAME], 42 | password=entry.data[CONF_PASSWORD], 43 | base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), 44 | session=async_get_clientsession(hass), 45 | ) 46 | 47 | # Then getting the token. This token is a long life token, so initializaing at HA start up is enough 48 | loginResponse = await myLibrelinkLogin.async_get_token() 49 | sessionToken = loginResponse["token"] 50 | accountId = loginResponse["accountId"] 51 | 52 | # The retrieved token will be used to initiate the coordinator which will be used to update the data on a regular basis 53 | myLibrelinkClient = LibreLinkApiClient( 54 | token=sessionToken, 55 | base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), 56 | session=async_get_clientsession(hass), 57 | account_id=accountId, 58 | ) 59 | 60 | # Kept for later use in case historical data is needed 61 | # myLibreLinkGetGraph = LibreLinkGetGraph( 62 | # sessionToken, 63 | # session=async_get_clientsession(hass), 64 | # base_url=BASE_URL_LIST.get(entry.data[COUNTRY]), 65 | # patient_id="4cd06c35-28d0-11ec-ae45-0242ac110005", 66 | # ) 67 | # graph = await myLibreLinkGetGraph.async_get_data() 68 | # print(f"graph {graph}") 69 | 70 | hass.data[DOMAIN][entry.entry_id] = coordinator = LibreLinkDataUpdateCoordinator( 71 | hass=hass, 72 | client=myLibrelinkClient, 73 | ) 74 | 75 | # First poll of the data to be ready for entities initialization 76 | await coordinator.async_config_entry_first_refresh() 77 | 78 | # Then launch async_setup_entry for our declared entities in sensor.py and binary_sensor.py 79 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 80 | 81 | # Reload entry when its updated. 82 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 83 | 84 | return True 85 | 86 | 87 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 88 | """Handle removal of an entry.""" 89 | if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 90 | hass.data[DOMAIN].pop(entry.entry_id) 91 | return unloaded 92 | 93 | 94 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 95 | """Reload config entry when it changed.""" 96 | await async_unload_entry(hass, entry) 97 | await async_setup_entry(hass, entry) 98 | -------------------------------------------------------------------------------- /custom_components/librelink/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant import config_entries 10 | from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME 11 | from homeassistant.helpers import config_validation as cv, selector 12 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 13 | 14 | from .api import ( 15 | LibreLinkApiAuthenticationError, 16 | LibreLinkApiCommunicationError, 17 | LibreLinkApiError, 18 | LibreLinkApiLogin, 19 | ) 20 | from .const import BASE_URL_LIST, COUNTRY, COUNTRY_LIST, DOMAIN, LOGGER, MG_DL, MMOL_L 21 | 22 | # GVS: Init logger 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class LibreLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 27 | """Config flow for LibreLink.""" 28 | 29 | VERSION = 1 30 | 31 | async def async_step_user( 32 | self, 33 | user_input: dict | None = None, 34 | ) -> config_entries.FlowResult: 35 | """Handle a flow initialized by the user.""" 36 | _errors = {} 37 | if user_input is not None: 38 | try: 39 | await self._test_credentials( 40 | username=user_input[CONF_USERNAME], 41 | password=user_input[CONF_PASSWORD], 42 | base_url=BASE_URL_LIST.get(user_input[COUNTRY]), 43 | ) 44 | except LibreLinkApiAuthenticationError as exception: 45 | LOGGER.warning(exception) 46 | _errors["base"] = "auth" 47 | except LibreLinkApiCommunicationError as exception: 48 | LOGGER.error(exception) 49 | _errors["base"] = "connection" 50 | except LibreLinkApiError as exception: 51 | LOGGER.exception(exception) 52 | _errors["base"] = "unknown" 53 | else: 54 | return self.async_create_entry( 55 | title=user_input[CONF_USERNAME], 56 | data=user_input, 57 | ) 58 | 59 | return self.async_show_form( 60 | step_id="user", 61 | data_schema=vol.Schema( 62 | { 63 | vol.Required( 64 | CONF_USERNAME, 65 | default=(user_input or {}).get(CONF_USERNAME), 66 | ): selector.TextSelector( 67 | selector.TextSelectorConfig( 68 | type=selector.TextSelectorType.TEXT 69 | ), 70 | ), 71 | vol.Required(CONF_PASSWORD): selector.TextSelector( 72 | selector.TextSelectorConfig( 73 | type=selector.TextSelectorType.PASSWORD 74 | ), 75 | ), 76 | vol.Required( 77 | COUNTRY, 78 | description="Country", 79 | default=(COUNTRY_LIST[0]), 80 | ): vol.In(COUNTRY_LIST), 81 | vol.Required( 82 | CONF_UNIT_OF_MEASUREMENT, 83 | default=(MG_DL), 84 | ): vol.In({MG_DL, MMOL_L}), 85 | } 86 | ), 87 | errors=_errors, 88 | ) 89 | 90 | async def _test_credentials( 91 | self, username: str, password: str, base_url: str 92 | ) -> None: 93 | """Validate credentials.""" 94 | client = LibreLinkApiLogin( 95 | username=username, 96 | password=password, 97 | base_url=base_url, 98 | session=async_create_clientsession(self.hass), 99 | ) 100 | 101 | await client.async_get_token() 102 | 103 | -------------------------------------------------------------------------------- /custom_components/librelink/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for LibreLink.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | import logging 7 | import time 8 | 9 | from homeassistant.components.sensor import SensorEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_UNIT_OF_MEASUREMENT 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from .const import ( 16 | DOMAIN, 17 | GLUCOSE_TREND_ICON, 18 | GLUCOSE_TREND_MESSAGE, 19 | GLUCOSE_VALUE_ICON, 20 | MG_DL, 21 | MMOL_DL_TO_MG_DL, 22 | MMOL_L, 23 | ) 24 | from .coordinator import LibreLinkDataUpdateCoordinator 25 | from .device import LibreLinkDevice 26 | 27 | # GVS: Tuto pour ajouter des log 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | """ Three sensors are declared: 31 | Glucose Value 32 | Glucose Trend 33 | Sensor days and related sensor attributes""" 34 | 35 | 36 | async def async_setup_entry( 37 | hass: HomeAssistant, 38 | config_entry: ConfigEntry, 39 | async_add_entities: AddEntitiesCallback, 40 | ): 41 | """Set up the sensor platform.""" 42 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 43 | 44 | # If custom unit of measurement is selectid it is initialized, otherwise MG/DL is used 45 | try: 46 | custom_unit = config_entry.data[CONF_UNIT_OF_MEASUREMENT] 47 | except KeyError: 48 | custom_unit = MG_DL 49 | 50 | # For each patients, new Device base on patients and 51 | # using an index as we need to keep the coordinator in the @property to get updates from coordinator 52 | # we create an array of entities then create entities. 53 | 54 | sensors = [] 55 | for index, patients in enumerate(coordinator.data): 56 | 57 | sensors.extend( 58 | [ 59 | LibreLinkSensor( 60 | coordinator, 61 | index, 62 | "value", # key 63 | "Glucose Measurement", # name 64 | custom_unit, 65 | ), 66 | LibreLinkSensor( 67 | coordinator, 68 | index, 69 | "trend", # key 70 | "Trend", # name 71 | custom_unit, 72 | ), 73 | LibreLinkSensor( 74 | coordinator, 75 | index, 76 | "sensor", # key 77 | "Active Sensor", # name 78 | "days", # uom 79 | ), 80 | LibreLinkSensor( 81 | coordinator, 82 | index, 83 | "delay", # key 84 | "Minutes since update", # name 85 | "min", # uom 86 | ), 87 | ] 88 | ) 89 | 90 | async_add_entities(sensors) 91 | 92 | 93 | class LibreLinkSensor(LibreLinkDevice, SensorEntity): 94 | """LibreLink Sensor class.""" 95 | 96 | def __init__( 97 | self, 98 | coordinator: LibreLinkDataUpdateCoordinator, 99 | index, 100 | key: str, 101 | name: str, 102 | uom, 103 | ) -> None: 104 | """Initialize the device class.""" 105 | super().__init__(coordinator, index) 106 | self.uom = uom 107 | self.patients = ( 108 | self.coordinator.data[index]["firstName"] 109 | + " " 110 | + self.coordinator.data[index]["lastName"] 111 | ) 112 | self.patientId = self.coordinator.data[index]["patientId"] 113 | self._attr_unique_id = f"{self.coordinator.data[index]['patientId']}_{key}" 114 | self._attr_name = name 115 | self.index = index 116 | self.key = key 117 | 118 | @property 119 | def native_value(self): 120 | """Return the native value of the sensor.""" 121 | 122 | result = None 123 | 124 | # to avoid failing requests if there is no activated sensor for a patient. 125 | if self.coordinator.data[self.index] is not None: 126 | if self.key == "value": 127 | if self.uom == MG_DL: 128 | result = int( 129 | self.coordinator.data[self.index]["glucoseMeasurement"][ 130 | "ValueInMgPerDl" 131 | ] 132 | ) 133 | if self.uom == MMOL_L: 134 | result = round( 135 | float( 136 | self.coordinator.data[self.index][ 137 | "glucoseMeasurement" 138 | ]["ValueInMgPerDl"] 139 | / MMOL_DL_TO_MG_DL 140 | ), 141 | 1, 142 | ) 143 | 144 | elif self.key == "trend": 145 | result = GLUCOSE_TREND_MESSAGE[ 146 | ( 147 | self.coordinator.data[self.index]["glucoseMeasurement"][ 148 | "TrendArrow" 149 | ] 150 | ) 151 | - 1 152 | ] 153 | 154 | elif self.key == "sensor": 155 | if self.coordinator.data[self.index]["sensor"] is not None: 156 | result = int( 157 | ( 158 | time.time() 159 | - (self.coordinator.data[self.index]["sensor"]["a"]) 160 | ) 161 | / 86400 162 | ) 163 | 164 | elif self.key == "delay": 165 | result = int( 166 | ( 167 | datetime.now() 168 | - datetime.strptime( 169 | self.coordinator.data[self.index][ 170 | "glucoseMeasurement" 171 | ]["Timestamp"], 172 | "%m/%d/%Y %I:%M:%S %p", 173 | ) 174 | ).total_seconds() 175 | / 60 # convert seconds in minutes 176 | ) 177 | 178 | return result 179 | return None 180 | 181 | @property 182 | def icon(self): 183 | """Return the icon for the frontend.""" 184 | 185 | if self.coordinator.data[self.index]["glucoseMeasurement"]["TrendArrow"]: 186 | if self.key in ["value", "trend"]: 187 | return GLUCOSE_TREND_ICON[ 188 | ( 189 | self.coordinator.data[self.index]["glucoseMeasurement"][ 190 | "TrendArrow" 191 | ] 192 | ) 193 | - 1 194 | ] 195 | return GLUCOSE_VALUE_ICON 196 | 197 | @property 198 | def unit_of_measurement(self): 199 | """Only used for glucose measurement and librelink sensor delay since update.""" 200 | 201 | if self.coordinator.data[self.index]: 202 | if self.key in ["sensor", "value"]: 203 | return self.uom 204 | return None 205 | 206 | @property 207 | def extra_state_attributes(self): 208 | """Return the state attributes of the librelink sensor.""" 209 | result = None 210 | if self.coordinator.data[self.index]: 211 | if self.key == "sensor": 212 | if self.coordinator.data[self.index]["sensor"] is not None: 213 | result = { 214 | "Serial number": f"{self.coordinator.data[self.index]['sensor']['pt']} {self.coordinator.data[self.index]['sensor']['sn']}", 215 | "Activation date": datetime.fromtimestamp( 216 | self.coordinator.data[self.index]["sensor"]["a"] 217 | ), 218 | "patientId": self.coordinator.data[self.index]["patientId"], 219 | "Patient": f"{(self.coordinator.data[self.index]['lastName']).upper()} {self.coordinator.data[self.index]['firstName']}", 220 | } 221 | 222 | return result 223 | return result 224 | -------------------------------------------------------------------------------- /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 | import asyncio 6 | import hashlib 7 | import logging 8 | import socket 9 | 10 | import aiohttp 11 | 12 | from .const import ( 13 | API_TIME_OUT_SECONDS, 14 | APPLICATION, 15 | CONNECTION_URL, 16 | LOGIN_URL, 17 | PRODUCT, 18 | VERSION_APP, 19 | ) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class LibreLinkApiClient: 25 | """API class to retriev measurement data. 26 | 27 | Attributes: 28 | token: The long life token to authenticate. 29 | base_url: For API calls depending on your location 30 | Session: aiottp object for the open session 31 | account_id_hash: SHA256 hash of the account ID for API authentication 32 | """ 33 | 34 | def __init__( 35 | self, token: str, base_url: str, session: aiohttp.ClientSession, account_id: str = None 36 | ) -> None: 37 | """Sample API Client.""" 38 | self._token = token 39 | self._session = session 40 | self.connection_url = base_url + CONNECTION_URL 41 | # Hash the account ID for the account-id header (required as of version 4.16.0) 42 | self._account_id_hash = hashlib.sha256(account_id.encode()).hexdigest() if account_id else None 43 | 44 | async def async_get_data(self) -> any: 45 | """Get data from the API.""" 46 | # Build headers with required account-id hash (required as of API version 4.16.0) 47 | headers = { 48 | "accept-encoding": "gzip", 49 | "cache-control": "no-cache", 50 | "connection": "Keep-Alive", 51 | "content-type": "application/json", 52 | "product": PRODUCT, 53 | "version": VERSION_APP, 54 | "authorization": "Bearer " + self._token, 55 | } 56 | 57 | # Add hashed account ID if available 58 | if self._account_id_hash: 59 | headers["account-id"] = self._account_id_hash 60 | 61 | APIreponse = await api_wrapper( 62 | self._session, 63 | method="get", 64 | url=self.connection_url, 65 | headers=headers, 66 | data={}, 67 | ) 68 | 69 | # Ordering API response by patients as the API does not always send patients in the same order 70 | # This temporary solution works only when you do not add a new Patient in your account. 71 | # 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. 72 | # 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. 73 | 74 | _LOGGER.debug( 75 | "Return API Status:%s ", 76 | APIreponse["status"], 77 | ) 78 | 79 | # API status return 0 if everything goes well. 80 | if APIreponse["status"] == 0: 81 | patients = sorted(APIreponse["data"], key=lambda x: x["patientId"]) 82 | else: 83 | patients = APIreponse # to be used for debugging in status not ok 84 | 85 | _LOGGER.debug( 86 | "Number of patients : %s and patient list %s", 87 | len(patients), 88 | patients, 89 | ) 90 | 91 | return patients 92 | 93 | 94 | class LibreLinkGetGraph: 95 | """API class to retriev measurement data. 96 | 97 | Attributes: 98 | token: The long life token to authenticate. 99 | base_url: For API calls depending on your location 100 | Session: aiottp object for the open session 101 | patientId: As this API retreive data for a specified patient 102 | """ 103 | 104 | def __init__( 105 | self, token: str, base_url: str, session: aiohttp.ClientSession, patient_id: str 106 | ) -> None: 107 | """Sample API Client.""" 108 | self._token = token 109 | self._session = session 110 | self.connection_url = base_url + CONNECTION_URL 111 | self.patient_id = patient_id 112 | 113 | async def async_get_data(self) -> any: 114 | """Get data from the API.""" 115 | APIreponse = await api_wrapper( 116 | self._session, 117 | method="get", 118 | url=self.connection_url, 119 | headers={ 120 | "product": PRODUCT, 121 | "version": VERSION_APP, 122 | "Application": APPLICATION, 123 | "Authorization": "Bearer " + self._token, 124 | "patientid": self.patient_id, 125 | }, 126 | data={}, 127 | ) 128 | 129 | _LOGGER.debug( 130 | "Get Connection : %s", 131 | APIreponse, 132 | ) 133 | 134 | return APIreponse 135 | 136 | 137 | class LibreLinkApiLogin: 138 | """API class to retriev token. 139 | 140 | Attributes: 141 | username: of the librelink account 142 | password: of the librelink account 143 | base_url: For API calls depending on your location 144 | Session: aiottp object for the open session 145 | """ 146 | 147 | def __init__( 148 | self, 149 | username: str, 150 | password: str, 151 | base_url: str, 152 | session: aiohttp.ClientSession, 153 | ) -> None: 154 | """Sample API Client.""" 155 | self._username = username 156 | self._password = password 157 | self.login_url = base_url + LOGIN_URL 158 | self._session = session 159 | 160 | async def async_get_token(self) -> dict: 161 | """Get token and account ID from the API.""" 162 | reponseLogin = await api_wrapper( 163 | self._session, 164 | method="post", 165 | url=self.login_url, 166 | headers={ 167 | "product": PRODUCT, 168 | "version": VERSION_APP, 169 | "Application": APPLICATION, 170 | }, 171 | data={"email": self._username, "password": self._password}, 172 | ) 173 | _LOGGER.debug( 174 | "Login status : %s", 175 | reponseLogin["status"], 176 | ) 177 | if reponseLogin["status"]==2: 178 | raise LibreLinkApiAuthenticationError( 179 | "Invalid credentials", 180 | ) 181 | 182 | monToken = reponseLogin["data"]["authTicket"]["token"] 183 | accountId = reponseLogin["data"]["user"]["id"] 184 | 185 | return {"token": monToken, "accountId": accountId} 186 | 187 | 188 | ################################################################ 189 | # """Utilitises """ # 190 | ################################################################ 191 | 192 | 193 | @staticmethod 194 | async def api_wrapper( 195 | session: aiohttp.ClientSession, 196 | method: str, 197 | url: str, 198 | data: dict | None = None, 199 | headers: dict | None = None, 200 | ) -> any: 201 | """Get information from the API.""" 202 | try: 203 | async with asyncio.timeout(API_TIME_OUT_SECONDS): 204 | response = await session.request( 205 | method=method, 206 | url=url, 207 | headers=headers, 208 | json=data, 209 | ) 210 | _LOGGER.debug("response.status: %s", response.status) 211 | if response.status in (401, 403): 212 | raise LibreLinkApiAuthenticationError( 213 | "Invalid credentials", 214 | ) 215 | response.raise_for_status() 216 | # 217 | return await response.json() 218 | 219 | except asyncio.TimeoutError as exception: 220 | raise LibreLinkApiCommunicationError( 221 | "Timeout error fetching information", 222 | ) from exception 223 | except (aiohttp.ClientError, socket.gaierror) as exception: 224 | raise LibreLinkApiCommunicationError( 225 | "Error fetching information", 226 | ) from exception 227 | except Exception as exception: # pylint: disable=broad-except 228 | raise LibreLinkApiError("Something really wrong happened!") from exception 229 | 230 | 231 | class LibreLinkApiError(Exception): 232 | """Exception to indicate a general API error.""" 233 | 234 | _LOGGER.debug("Exception: general API error") 235 | 236 | 237 | class LibreLinkApiCommunicationError(LibreLinkApiError): 238 | """Exception to indicate a communication error.""" 239 | 240 | _LOGGER.debug("Exception: communication error") 241 | 242 | 243 | class LibreLinkApiAuthenticationError(LibreLinkApiError): 244 | """Exception to indicate an authentication error.""" 245 | 246 | _LOGGER.debug("Exception: authentication error") 247 | --------------------------------------------------------------------------------