From e37a546c9c5bb49fc113332453288969ce294641 Mon Sep 17 00:00:00 2001 From: sedy <65983953+sedy89@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:11:38 +0100 Subject: [PATCH] update auth process; implemented scan interval config; usage of HA timezone in ns uploader (#68) --- .devcontainer.json | 42 ++ .devcontainer/README.md | 43 -- .devcontainer/configuration.yml | 11 - .devcontainer/devcontainer.json | 43 -- .gitignore | 5 +- .vscode/tasks.json | 22 +- README.md | 41 +- config/configuration.yaml | 9 + custom_components/carelink/__init__.py | 27 +- custom_components/carelink/api.py | 508 ++++++++---------- custom_components/carelink/config_flow.py | 33 +- custom_components/carelink/const.py | 1 + .../carelink/nightscout_uploader.py | 15 +- custom_components/carelink/strings.json | 8 +- .../carelink/translations/de.json | 12 +- .../carelink/translations/en.json | 10 +- .../carelink/translations/fr.json | 10 +- .../carelink/translations/nl.json | 10 +- requirements.txt | 4 + script/bootstrap | 11 - script/setup | 38 -- scripts/develop | 20 + scripts/init | 7 + scripts/setup | 7 + utils/carelink_carepartner_api_login.py | 254 +++++++++ 25 files changed, 691 insertions(+), 500 deletions(-) create mode 100644 .devcontainer.json delete mode 100644 .devcontainer/README.md delete mode 100644 .devcontainer/configuration.yml delete mode 100644 .devcontainer/devcontainer.json create mode 100644 config/configuration.yaml create mode 100644 requirements.txt delete mode 100644 script/bootstrap delete mode 100644 script/setup create mode 100644 scripts/develop create mode 100644 scripts/init create mode 100644 scripts/setup create mode 100644 utils/carelink_carepartner_api_login.py diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..9e8c56c --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "ludeeus/integration_blueprint", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "sudo -E bash scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} \ No newline at end of file diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 60458f6..0000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,43 +0,0 @@ -## Developing with Visual Studio Code + devcontainer - -The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. - -In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. - -**Prerequisites** - -- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- Docker - - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) - - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. -- [Visual Studio code](https://code.visualstudio.com/) -- [Remote - Containers (VSC Extension)][extension-link] - -[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) - -[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers - -**Getting started:** - -1. Fork the repository. -2. Clone the repository to your computer. -3. Open the repository using Visual Studio code. - -When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. - -_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ - -### Tasks - -The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. - -When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. - -The available tasks are: - -Task | Description --- | -- -Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. -Run Home Assistant configuration against /config | Check the configuration. -Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. -Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. diff --git a/.devcontainer/configuration.yml b/.devcontainer/configuration.yml deleted file mode 100644 index 755adcd..0000000 --- a/.devcontainer/configuration.yml +++ /dev/null @@ -1,11 +0,0 @@ -default_config: - -logger: - default: info - logs: - custom_components.carelink: debug - -debugpy: - start: true - wait: false - port: 5678 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 8f6757a..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,43 +0,0 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. -{ - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "Carelink integration development", - "context": "..", - "appPort": ["9123:8123"], - "postCreateCommand": "script/setup", - "postStartCommand": "script/bootstrap", - "extensions": [ - "ms-python.vscode-pylance", - "visualstudioexptteam.vscodeintellicode", - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "GitHub.vscode-pull-request-github" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.profiles.linux": { - "zsh": { - "path": "/usr/bin/zsh" - } - }, - "terminal.integrated.defaultProfile.linux": "zsh", - "python.pythonPath": "/usr/bin/python", - "python.testing.pytestEnabled": true, - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true, - "files.associations": { - "*.yaml": "home-assistant" - }, - "editor.defaultFormatter": null, - "[javascript, yaml, json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2adbea8..cbf15c0 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,7 @@ cython_debug/ #.idea/ *.DS_Store -cookies.txt +config/** +!config/configuration.yaml +/custom_components/carelink/logindata.json +/utils/logindata.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7ab4ba8..4ff333f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,27 +2,9 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", + "command": "sudo bash scripts/develop", "problemMatcher": [] } ] diff --git a/README.md b/README.md index f63b269..d0ec983 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,43 @@ Copy the `custom_components/carelink` to your `custom_components` folder. Reboot ## Integration Setup -### Session Token -In order to authenticate to the Carelink server, the Carelink client needs a valid access token. This can be obtained by manually logging into a Carelink follower account via Carelink web page. After successful login, the access token (plus country code) can be shown and copied using the Cookie Quick Manager Firefox plugin as follows: +### Carelink Login Data +The needed information for the authentification process can either be provided as file (=logindata.json), or entered during the initial setup of the integration. +#### Get the data +The Home Assistant Carelink Integration needs the initial login data stored in the `logindata.json` file. This file can be created **by running the login script on a PC with a screen**. +The login script from [@ondrej1024](https://github.com/ondrej1024)'s Carelink Python API, written by @palmarci (Pal Marci), was slightly modified and can be found here ["carelink_carepartner_api_login.py"](https://github.com/sedy89/Home-Assistant-Carelink/blob/json_login/utils/carelink_carepartner_api_login.py). -- With the Carelink web page still active, open Cookie Quick Manger from the extensions menu -- Select option "Search Cookies: carelink.minimed.eu" -- Copy value of auth temp token and use it as Session token for initial setup of the Homeassistant Carelink integration +Simply run: +``` +python carelink_carepartner_api_login.py +``` + +You might need to install the following Python packages to satisfy the script's dependencies: + +``` +- requests +- OpenSSL (pip install pyOpenSSL) +- seleniumwire (pip install selenium-wire) +``` + +The script opens a Firefox web browser with the Carelink login page. You have to provide your Carelink patients or follower credentials (recommended) and solve the reCapcha. +On successful completion of the login, the data file will be created with the following structure: + +![grafik](https://github.com/sedy89/Home-Assistant-Carelink/assets/65983953/35a60542-03fc-4deb-a14c-c96b0155bdd4) + +#### Provide the data +Either the content of the `logindata.json` file can be taken over into the setup of the HA Carelink integration, or the entire file can be uploaded into the custom_componend/carelink folder (recommended). + +![grafik](https://github.com/sedy89/Home-Assistant-Carelink/assets/65983953/0a1d8773-7905-4fec-9bff-b3a0f01817b9) + +All parameters during setup are optional and a provided file will have a higher priority and overwrite the manual configuration. +If the file was copied to `custom_components/carelink` before the integration setup was started in Home Assistant, all parameters during the setup can stay empty. +With those information, the Home Assistant Carelink Integration will be able to automatically refresh the login data when it expires. +It should be able to do so within one week of the last refresh. + +### Scan Interval +The scan interval of the integration can be configured during the integration setup. +User can configure anything between 30 and 300 seconds. Default is 60 seconds. ### Nightscout To use the Nightscout uploader, it is mandatory to provide the Nightscout URL and the Nightscout API secret. diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..9384c6a --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,9 @@ + +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + +logger: + default: info + logs: + custom_components.carelink: debug diff --git a/custom_components/carelink/__init__.py b/custom_components/carelink/__init__.py index e84d8e7..3c1ea6a 100644 --- a/custom_components/carelink/__init__.py +++ b/custom_components/carelink/__init__.py @@ -21,6 +21,7 @@ CLIENT, UPLOADER, DOMAIN, + SCAN_INTERVAL, COORDINATOR, UNAVAILABLE, DEVICE_PUMP_MODEL, @@ -82,8 +83,6 @@ _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=60) - def convert_date_to_isodate(date): date_iso = re.sub("\.\d{3}Z$", "+00:00", date) @@ -97,27 +96,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) config = entry.data - patientId = None - - if "patientId" in config: - patientId = config["patientId"] carelink_client = CarelinkClient( - config["country"], - config["token"], - patientId + config["cl_token"], + config["cl_refresh_token"], + config["cl_client_id"], + config["cl_client_secret"], + config["cl_mag_identifier"], + config["patientId"] ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {CLIENT: carelink_client} - if "nightscout_url" in config and "nightscout_api" in config: + if config["nightscout_url"] and config["nightscout_api"]: nightscout_uploader = NightscoutUploader( config["nightscout_url"], config["nightscout_api"] ) hass.data.setdefault(DOMAIN, {})[entry.entry_id].update({UPLOADER: nightscout_uploader}) - coordinator = CarelinkCoordinator(hass, entry, update_interval=SCAN_INTERVAL) + coordinator = CarelinkCoordinator(hass, entry, update_interval=timedelta(seconds=config[SCAN_INTERVAL])) await coordinator.async_config_entry_first_refresh() @@ -163,9 +161,6 @@ async def _async_update_data(self): recent_data = await self.client.get_recent_data() if recent_data is None: recent_data = dict() - else: - if self.uploader: - await self.uploader.send_recent_data(recent_data) try: if recent_data is not None and "clientTimeZoneName" in recent_data: client_timezone = recent_data["clientTimeZoneName"] @@ -188,6 +183,10 @@ async def _async_update_data(self): _LOGGER.debug("Using timezone %s", DEFAULT_TIME_ZONE) + # nightscout uploader + if self.uploader: + await self.uploader.send_recent_data(recent_data, timezone) + recent_data["sLastSensorTime"] = recent_data.setdefault("sLastSensorTime", "") recent_data["activeInsulin"] = recent_data.setdefault("activeInsulin", {}) recent_data["basal"] = recent_data.setdefault("basal", {}) diff --git a/custom_components/carelink/api.py b/custom_components/carelink/api.py index b4297ab..08d9b4f 100644 --- a/custom_components/carelink/api.py +++ b/custom_components/carelink/api.py @@ -30,24 +30,19 @@ from datetime import datetime, timedelta, timezone import json import logging -import time import os import base64 import httpx # Version string -VERSION = "0.3" +VERSION = "0.4" # Constants -CARELINK_CONNECT_SERVER_EU = "carelink.minimed.eu" -CARELINK_CONNECT_SERVER_US = "carelink.minimed.com" -CARELINK_LANGUAGE_EN = "en" -CARELINK_AUTH_TOKEN_COOKIE_NAME = "auth_tmp_token" -CARELINK_TOKEN_VALIDTO_COOKIE_NAME = "c_token_valid_to" AUTH_EXPIRE_DEADLINE_MINUTES = 10 - -CON_CONTEXT_COOKIE = "custom_components/carelink/cookies.txt" +CON_CONTEXT_AUTH = "custom_components/carelink/logindata.json" +CARELINK_CONFIG_URL = "https://clcloud.minimed.com/connect/carepartner/v6/discover/android/3.1" +AUTH_ERROR_CODES = [401,403] DEBUG = False @@ -66,71 +61,52 @@ class CarelinkClient: def __init__( self, - carelink_country, + carelink_refresh_token, carelink_token, - carelink_patient_id=None, + client_id, + client_secret, + mag_identifier, + carelink_patient_id ): - # User info - self.__carelink_country = carelink_country.lower() - _LOGGER.debug("Carelink country: %s", self.__carelink_country) - self.__carelink_auth_token = carelink_token + # Auth info + self.__carelink_refresh_token = carelink_refresh_token + self.__carelink_access_token = carelink_token + self.__client_id = client_id + self.__client_secret = client_secret + self.__mag_identifier = mag_identifier + self.__tokenData = None + self.__accessTokenPayload = None + # helper token data self.__auth_token_validto = None - self.__carelink_patient_id = carelink_patient_id - # Session info + self.__carelink_patient_id = carelink_patient_id self.__session_user = None - self.__session_profile = None - self.__session_country_settings = None - self.__session_monitor_data = None + self.__session_username = None + self.__session_config = None + self.__session_country = None # State info - self.__logged_in = False - self.__last_data_success = False + self.__initialized = False self.__last_response_code = None - self.__last_error_message = None self._async_client = None - self._cookies = None - self.__common_headers = { - # Common browser headers - "Accept-Language": "en;q=0.9, *;q=0.8", - "Connection": "keep-alive", - "sec-ch-ua":"\"Google Chrome\";v=\"115\", \" Not;A Brand\";v=\"99\", \"Chromium\";v=\"115\"", - "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - } + self.__common_headers = { + # Common browser headers + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 10; Nexus 5X Build/QQ3A.200805.001)", + } @property def async_client(self): """Return the httpx client.""" if not self._async_client: self._async_client = httpx.AsyncClient() - return self._async_client - def get_last_data_success(self): - """Return __last_data_success.""" - return self.__last_data_success - - def get_last_response_code(self): - """Return __last_response_code.""" - return self.__last_response_code - - def getlast_error_message(self): - """Return __last_error_message.""" - return self.__last_error_message - - def __carelink_server(self): - """Return carelink server domain by country.""" - return ( - CARELINK_CONNECT_SERVER_US - if self.__carelink_country == "us" - else CARELINK_CONNECT_SERVER_EU - ) - async def fetch_async(self, url, headers, params=None): """Perform an async get request.""" response = await self.async_client.get( @@ -140,7 +116,6 @@ async def fetch_async(self, url, headers, params=None): follow_redirects=True, timeout=30, ) - return response async def post_async(self, url, headers, data=None, params=None): @@ -153,27 +128,25 @@ async def post_async(self, url, headers, data=None, params=None): follow_redirects=True, timeout=30, ) - return response - async def __get_data(self, host, path, query_params, request_body): + async def __get_data(self, path, query_params, request_body): printdbg("__get_data()") - self.__last_data_success = False - if host is None: - url = path + if path is None: + url = self.__session_config["baseUrlCumulus"] + "/display/message" else: - url = "https://" + host + "/" + path + url = path payload = query_params data = request_body jsondata = None # Get auth token - auth_token = await self.__get_authorization_token() - if auth_token is not None: + if await self.__handle_authorization_token(): try: # Add header headers = self.__common_headers - headers["Authorization"] = auth_token + headers["mag-identifier"] = self.__tokenData["mag-identifier"] + headers["Authorization"] = "Bearer " + self.__tokenData["access_token"] if data is None: headers["Accept"] = "application/json, text/plain, */*" headers["Content-Type"] = "application/json; charset=utf-8" @@ -194,7 +167,6 @@ async def __get_data(self, host, path, query_params, request_body): response = await self.post_async(url, headers=headers, data=data) self.__last_response_code = response.status_code if not response.status_code == 200: - printdbg(response.status_code) raise ValueError( "__get_data() session get response is not OK" + str(response.status_code) @@ -204,7 +176,6 @@ async def __get_data(self, host, path, query_params, request_body): printdbg(f"__get_data() failed: exception {error}") else: jsondata = json.loads(response.text) - self.__last_data_success = True return jsondata @@ -219,53 +190,67 @@ def __selectPatient(self, patients): async def __getPatients(self): printdbg("__getPatients()") + url = self.__session_config["baseUrlCareLink"] + "/links/patients" return await self.__get_data( - self.__carelink_server(), "patient/m2m/links/patients", None, None + url, None, None ) async def __get_my_user(self): printdbg("__get_my_user()") - return await self.__get_data( - self.__carelink_server(), "patient/users/me", None, None - ) + url = self.__session_config["baseUrlCareLink"] + "/users/me" + resp = await self.__get_data( + url, None, None) + return resp - async def __get_my_profile(self): - printdbg("__get_my_profile()") - return await self.__get_data( - self.__carelink_server(), "patient/users/me/profile", None, None - ) - - async def __get_country_settings(self, country, language): - printdbg("__get_country_settings()") - query_params = {"countryCode": country, "language": language} - return await self.__get_data( - self.__carelink_server(), "patient/countries/settings", query_params, None - ) - - async def __get_monitor_data(self): - printdbg("__get_monitor_data()") - return await self.__get_data( - self.__carelink_server(), - "patient/monitor/data", - None, - None, - ) + async def __get_config_settings(self): + printdbg("__get_config_settings()") + try: + resp = await self.fetch_async(CARELINK_CONFIG_URL, self.__common_headers) + self.__last_response_code = resp.status_code + if not resp.status_code == 200: + raise ValueError( + "__get_config_settings() CARELINK_CONFIG_URL session get response is not OK" + + str(resp.status_code) + ) + data = resp.json() + region = None + config = None - # Old last24hours webapp data - async def __get_last24_hours(self): - printdbg("__get_last24_hours") - query_params = { - "cpSerialNumber": "NONE", - "msgType": "last24hours", - "requestTime": str(int(time.time() * 1000)), - } - return await self.__get_data( - self.__carelink_server(), "patient/connect/data", query_params, None - ) + for c in data["supportedCountries"]: + try: + region = c[self.__session_country.upper()]["region"] + break + except KeyError: + pass + if region is None: + raise Exception("ERROR: country code %s is not supported" % self.__session_country) + printdbg("User region: %s" % region) + + for c in data["CP"]: + if c["region"] == region: + config = c + break + if config is None: + raise Exception(f"ERROR: failed to get config base urls for region {region}") + + resp = await self.fetch_async(config["SSOConfiguration"], self.__common_headers) + self.__last_response_code = resp.status_code + if not resp.status_code == 200: + raise ValueError( + "__get_config_settings() SSOConfiguration session GET response is not OK" + + str(resp.status_code) + ) + sso_config = resp.json() + sso_base_url = f"https://{sso_config['server']['hostname']}:{sso_config['server']['port']}/{sso_config['server']['prefix']}" + token_url = sso_base_url + sso_config["oauth"]["system_endpoints"]["token_endpoint_path"] + config["token_url"] = token_url + except Exception as e: + printdbg(e) + return config # Periodic data from CareLink Cloud async def __get_connect_display_message( - self, username, role, endpoint_url, patient_id=None + self, username, role, patient_id=None ): printdbg("__get_connect_display_message()") @@ -276,77 +261,21 @@ async def __get_connect_display_message( user_json["patientId"] = patient_id request_body = json.dumps(user_json) - recent_data = await self.__get_data(None, endpoint_url, None, request_body) - if recent_data is not None: - self.__correct_time_in_recent_data(recent_data) + recent_data = await self.__get_data(None, None, request_body) return recent_data - def __correct_time_in_recent_data(self, recent_data): - # TODO - pass - - async def __execute_login_procedure(self): - - last_login_success = False - self.__last_error_message = None + async def _get_access_token_payload(self, token_data): + printdbg("_get_access_token_payload()") try: - # Clear cookies - self.async_client.cookies.clear() - - # Clear basic infos - self.__session_user = None - self.__session_profile = None - self.__session_country_settings = None - self.__session_monitor_data = None - - # Get sessions infos if required - if not self.__carelink_patient_id: - sessionPatients = await self.__getPatients() - patient = self.__selectPatient(sessionPatients) - if patient: - self.__carelink_patient_id = patient["username"] - printdbg("Found patient %s %s (%s)" % (patient["firstName"],patient["lastName"],self.__carelink_patient_id)) - else: - printdbg("No patient found.") - if self.__session_user is None: - self.__session_user = await self.__get_my_user() - if self.__session_profile is None: - self.__session_profile = await self.__get_my_profile() - if self.__session_country_settings is None: - self.__session_country_settings = await self.__get_country_settings( - self.__carelink_country, CARELINK_LANGUAGE_EN - ) - if self.__session_monitor_data is None: - self.__session_monitor_data = await self.__get_monitor_data() - - # Set login success if everything was ok: - if ( - self.__session_user is not None - and self.__session_profile is not None - and self.__session_country_settings is not None - and self.__session_monitor_data is not None - ): - last_login_success = True - - # pylint: disable=broad-except - except Exception as error: - printdbg(f"__execute_login_procedure() failed: exception {error}") - self.__last_error_message = error - - self.__logged_in = last_login_success - - return last_login_success - - async def __checkAuthorizationToken(self): - if self.__carelink_auth_token == None: - printdbg("No token found") - return False + token = token_data["access_token"] + except: + printdbg("no access token found") + return None try: # Decode json web token payload - payload_b64 = self.__carelink_auth_token.split('.')[1] + payload_b64 = token.split('.')[1] payload_b64_bytes = payload_b64.encode() missing_padding = (4 - len(payload_b64_bytes) % 4) % 4 - #print("missing_padding: %d" % missing_padding) if missing_padding: payload_b64_bytes += b'=' * missing_padding payload_bytes = base64.b64decode(payload_b64_bytes) @@ -357,127 +286,168 @@ async def __checkAuthorizationToken(self): token_validto = payload_json["exp"] token_validto -= 600 except: - printdbg("Malformed token") - return False - + printdbg("Malformed access token") + return None # Save expiration time self.__auth_token_validto = datetime.fromtimestamp(token_validto, tz=timezone.utc).strftime('%a %b %d %H:%M:%S UTC %Y') - # Check expiration time stamp - tdiff = token_validto - time.time() - if tdiff < 0: - printdbg("Token has expired %ds ago" % abs(tdiff)) - return False - - printdbg("Token expires in %ds (%s)" % (tdiff,self.__auth_token_validto)) - return True + return payload_json + + async def __execute_init_procedure(self): + printdbg("__execute_init_procedure()") + if not self.__initialized: + self.__tokenData = await self._process_token_file(CON_CONTEXT_AUTH) + + if self.__tokenData is None: + return + self.__accessTokenPayload = await self._get_access_token_payload(self.__tokenData) + if self.__accessTokenPayload is None: + return + try: + self.__session_country = self.__accessTokenPayload["token_details"]["country"] - async def __refreshToken(self, token): - printdbg("Trying to refresh token") + self.__session_config = await self.__get_config_settings() - if token == None: - printdbg("__refreshToken() no token to refresh") - return False + self.__session_username = self.__accessTokenPayload["token_details"]["preferred_username"] + self.__session_user = await self.__get_my_user() - success = True - url = "https://" + self.__carelink_server() + "/patient/sso/reauth" - headers = self.__common_headers - headers["Accept"] = "application/json, text/plain, */*" - headers["Authorization"] = "Bearer " + token + if self.__session_user["role"] in ["CARE_PARTNER","CARE_PARTNER_OUS"]: + if not self.__carelink_patient_id: + sessionPatients = await self.__getPatients() + patient = self.__selectPatient(sessionPatients) + if patient: + self.__carelink_patient_id = patient["username"] + printdbg("Found patient %s %s (%s)" % (patient["firstName"],patient["lastName"],self.__carelink_patient_id)) + else: + printdbg("No patient found.") + except Exception as error: + printdbg(f"__execute_init_procedure() failed: exception {error}") + if self.__last_response_code in AUTH_ERROR_CODES: + try: + if await self.__refreshToken(self.__session_config, self.__tokenData): + if await self._get_access_token_payload(self.__tokenData): + printdbg("New token is valid until " + self.__auth_token_validto) + await self._write_token_file(self.__tokenData, CON_CONTEXT_AUTH) + except Exception as e: + printdbg(e) + return + self.__initialized = True + return + + async def __refreshToken(self, config, token_data): + printdbg("__refreshToken") + success = False + token_url = config["token_url"] + + user_data = { + "refresh_token": token_data["refresh_token"], + "client_id": token_data["client_id"], + "client_secret": token_data["client_secret"], + "grant_type": "refresh_token" + } try: - response = await self.post_async(url, headers = headers) + headers = { + "mag-identifier": token_data["mag-identifier"] + } + printdbg("Trying to refresh token") + response = await self.post_async(url=token_url, headers=headers, data=user_data) self.__last_response_code = response.status_code - if response.status_code == 200: - printdbg("Token successfully refreshed") + if self.__last_response_code == 200: + printdbg("Refreshed token successfully") + response_data = response.json() + self.__tokenData["access_token"] = response_data["access_token"] + self.__tokenData["refresh_token"] = response_data["refresh_token"] + success = True else: - printdbg(response.status_code) - raise ValueError("session post response is not OK") + raise ValueError("Failed to refresh token (%d)" % self.__last_response_code) except Exception as e: printdbg(e) - printdbg("Failed to refresh token (%d)" % response.status_code) success = False return success - - async def __get_authorization_token(self): - auth_token = self.__carelink_auth_token - auth_token_validto = self.__auth_token_validto - - if auth_token == None or auth_token_validto == None: + async def __handle_authorization_token(self): + printdbg("__handle_authorization_token()") + if await self._get_access_token_payload(self.__tokenData): + auth_token_validto = self.__auth_token_validto + else: printdbg("No valid token") - return None + return False if (datetime.strptime(auth_token_validto, '%a %b %d %H:%M:%S UTC %Y').replace(tzinfo=timezone.utc) - datetime.now(tz=timezone.utc)) < timedelta(seconds=AUTH_EXPIRE_DEADLINE_MINUTES*60): - printdbg("Token is valid until " + self.__auth_token_validto) - if await self.__refreshToken(auth_token): - self.__carelink_auth_token = self.async_client.cookies[CARELINK_AUTH_TOKEN_COOKIE_NAME] - self.__auth_token_validto = self.async_client.cookies[CARELINK_TOKEN_VALIDTO_COOKIE_NAME] - printdbg("New token is valid until " + self.__auth_token_validto) - try: - cookie=os.path.join(os.getcwd(), CON_CONTEXT_COOKIE) - printdbg(f"Cookiefile: {cookie}") - with open(cookie, "w") as file: - file.write(self.__carelink_auth_token) - printdbg("Writing token to cookies.txt") - except: - printdbg("Failed to store token") - else: - # inital token is old, but updated token in file exists - try: - cookie=os.path.join(os.getcwd(), CON_CONTEXT_COOKIE) - with open(cookie, "r") as file: - self.__carelink_auth_token = file.read() - printdbg("Read cookies.txt token") - if not await self.__checkAuthorizationToken(): - printdbg("Manual login needed") - return None - except: - # Refresh failed, manual login needed - printdbg("Manual login needed") - return None - - # there can be only one - return "Bearer " + self.__carelink_auth_token + printdbg("Current token is valid until " + self.__auth_token_validto) + if await self.__refreshToken(self.__session_config, self.__tokenData): + if await self._get_access_token_payload(self.__tokenData): + printdbg("New token is valid until " + self.__auth_token_validto) + await self._write_token_file(self.__tokenData, CON_CONTEXT_AUTH) + return True # Wrapper for data retrival methods - async def get_recent_data(self): """Get most recent data.""" # Force login to get basic info - if await self.__get_authorization_token() is not None: - if ( - self.__carelink_country == "us" - or "BLE" in self.__session_monitor_data["deviceFamily"] - ): - role = ( - "carepartner" - if self.__session_user["role"] - in ["CARE_PARTNER", "CARE_PARTNER_OUS"] - else "patient" - ) - return await self.__get_connect_display_message( - self.__session_profile["username"], - role, - self.__session_country_settings["blePereodicDataEndpoint"], - self.__carelink_patient_id, - ) - else: - return await self.__get_last24_hours() + if await self.__handle_authorization_token(): + role = ( + "carepartner" + if self.__session_user["role"] + in ["CARE_PARTNER", "CARE_PARTNER_OUS"] + else "patient" + ) + return await self.__get_connect_display_message( + self.__session_username, + role, + self.__carelink_patient_id, + ) else: return None + async def _write_token_file(self, obj, filename): + printdbg("_write_token_file()") + with open(filename, 'w') as f: + json.dump(obj, f, indent=4) + + async def _process_token_file(self, filename): + printdbg("_process_token_file()") + token_data = None + if os.path.isfile(filename): + try: + token_data = json.loads(open(filename, "r").read()) + except json.JSONDecodeError: + printdbg("ERROR: failed parsing token file %s" % filename) + cfg_complete=True + if token_data is not None: + required_fields = ["access_token", "refresh_token", "client_id", "client_secret", "mag-identifier"] + for f in required_fields: + if f not in token_data: + printdbg("ERROR: field %s is missing from token file" % f) + cfg_complete=False + if not cfg_complete: + token_data=None + else: + printdbg(f"Authentification file {filename} does not exist.") + if self.__carelink_access_token and self.__carelink_refresh_token and self.__client_id and self.__client_secret and self.__mag_identifier: + printdbg(f"Found static configuration. Create Authentificaiton file.") + token_data = {"access_token" : self.__carelink_access_token, + "refresh_token" : self.__carelink_refresh_token, + "client_id" : self.__client_id, + "client_secret" : self.__client_secret, + "mag-identifier" : self.__mag_identifier, + } + await self._write_token_file(token_data, filename) + else: + printdbg("ERROR: No sufficient configuration found") + return token_data + # Authentication methods async def login(self): """perform login""" - if not self.__logged_in: - await self.__checkAuthorizationToken() - await self.__execute_login_procedure() - return self.__logged_in + if not self.__initialized: + await self.__execute_init_procedure() + return self.__initialized def run_in_console(self): """If running this module directly, print all the values in the console.""" print("Reading...") asyncio.run(self.login()) - if self.__logged_in: + if self.__initialized: result = asyncio.run(self.get_recent_data()) print(f"data: {result}") @@ -486,29 +456,21 @@ def run_in_console(self): parser = argparse.ArgumentParser( description="Retrieve recent data from last 24h from Medtronic Carelink." ) + parser.add_argument("-i", "--patientId", dest="carelink_patient", help="Carelink Patient ID") parser.add_argument("-t", "--token", dest="token", help="Carelink Token") - parser.add_argument( - "-i", "--patientId", dest="carelink_patient", help="Carelink Patient ID" - ) - parser.add_argument( - "-c", - "--country", - dest="country", - help="Carelink Country (US, NL, DE, AU, UK, etc)", - ) - + parser.add_argument("-r", "--rtoken", dest="refresh_token", help="Refresh Token") + parser.add_argument("-c", "--clientid", dest="client_id", help="Client ID") + parser.add_argument("-s", "--secret", dest="client_secret", help="Client Secret") + parser.add_argument("-m", "--mag", dest="mag_identifier", help="Mag Identifier") args = parser.parse_args() - if args.country is None: - raise ValueError("Country is required") - - if args.token is None: - raise ValueError("Token is required") - TESTAPI = CarelinkClient( - carelink_country=args.country, carelink_token = args.token, - carelink_patient_id = args.carelink_patient + carelink_patient_id = args.carelink_patient, + carelink_refresh_token = args.refresh_token, + client_id = args.client_id, + client_secret = args.client_secret, + mag_identifier = args.mag_identifier ) TESTAPI.run_in_console() diff --git a/custom_components/carelink/config_flow.py b/custom_components/carelink/config_flow.py index 8b13dd7..fe99134 100644 --- a/custom_components/carelink/config_flow.py +++ b/custom_components/carelink/config_flow.py @@ -13,48 +13,47 @@ from .api import CarelinkClient from .nightscout_uploader import NightscoutUploader -from .const import DOMAIN +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("country"): str, - vol.Required("token"): str, + vol.Optional("cl_token"): str, + vol.Optional("cl_refresh_token"): str, + vol.Optional("cl_client_id"): str, + vol.Optional("cl_client_secret"): str, + vol.Optional("cl_mag_identifier"): str, vol.Optional("patientId"): str, vol.Optional("nightscout_url"): str, vol.Optional("nightscout_api"): str, + vol.Required(SCAN_INTERVAL, default=60): vol.All(vol.Coerce(int), vol.Range(min=30, max=300)), } ) - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - patient_id = None - if "patientId" in data: - patient_id = data["patientId"] - client = CarelinkClient( - data["country"], data["token"], patient_id + data.setdefault("cl_token", None), + data.setdefault("cl_refresh_token", None), + data.setdefault("cl_client_id", None), + data.setdefault("cl_client_secret", None), + data.setdefault("cl_mag_identifier", None), + data.setdefault("patientId", None) ) if not await client.login(): raise InvalidAuth - nightscout_url = None - nightscout_api = None - if "nightscout_url" in data: - nightscout_url = data["nightscout_url"] - if "nightscout_api" in data: - nightscout_api = data["nightscout_api"] - + nightscout_url = data.setdefault("nightscout_url", None) + nightscout_api = data.setdefault("nightscout_api", None) if nightscout_api and nightscout_url: uploader = NightscoutUploader( - data["nightscout_url"], data["nightscout_api"] + nightscout_url, nightscout_api ) if not await uploader.reachServer(): raise ConnectionError diff --git a/custom_components/carelink/const.py b/custom_components/carelink/const.py index 5fad921..9b2b7e8 100644 --- a/custom_components/carelink/const.py +++ b/custom_components/carelink/const.py @@ -18,6 +18,7 @@ CLIENT = "carelink_client" COORDINATOR = "coordinator" UPLOADER = "nightscout_uploader" +SCAN_INTERVAL = "scan_interval" SENSOR_KEY_LASTSG_MMOL = "last_sg_mmol" SENSOR_KEY_LASTSG_MGDL = "last_sg_mgdl" diff --git a/custom_components/carelink/nightscout_uploader.py b/custom_components/carelink/nightscout_uploader.py index bf9f748..436d779 100644 --- a/custom_components/carelink/nightscout_uploader.py +++ b/custom_components/carelink/nightscout_uploader.py @@ -1,7 +1,6 @@ import argparse import asyncio from datetime import datetime -import pytz import hashlib import json import logging @@ -109,7 +108,8 @@ def __get_treatments(self, input, key, value): return result def __getDataStringFromIso(self, time, tz): - dt = tz.localize(datetime.fromisoformat(time.replace(".000-00:00", ""))) + dt = datetime.fromisoformat(time.replace(".000-00:00", "")) + dt = dt.astimezone(tz) timestamp = dt.timestamp() date = int(timestamp * 1000) date_string = dt.isoformat() @@ -117,7 +117,7 @@ def __getDataStringFromIso(self, time, tz): def __getDataString(self, time, tz): dt = datetime.strptime(time,"%Y-%m-%dT%H:%M:%S.%fZ") - dt = tz.localize(dt) + dt = dt.astimezone(tz) timestamp = dt.timestamp() date = int(timestamp * 1000) date_string = dt.isoformat() @@ -388,10 +388,7 @@ def __getSGSEntries(self, sgs, tz): noise=1)) return result - async def __slice_recent_data_for_transmission(self, recent_data): - # get timezone - local_tz = datetime.now().astimezone().tzname() - tz= pytz.timezone(local_tz) + async def __slice_recent_data_for_transmission(self, recent_data, tz): # Sending device status response = await self.__setDeviceStatus(recent_data) if response: @@ -425,10 +422,10 @@ async def __slice_recent_data_for_transmission(self, recent_data): # Periodic upload to Nightscout async def send_recent_data( - self, recent_data + self, recent_data, timezone ): printdbg("__send_recent_data()") - await self.__slice_recent_data_for_transmission(recent_data) + await self.__slice_recent_data_for_transmission(recent_data, timezone) async def __test_server_connection(self): url = f"{self.__nightscout_url}/api/v1/devicestatus.json" diff --git a/custom_components/carelink/strings.json b/custom_components/carelink/strings.json index e1e5a63..be2972c 100644 --- a/custom_components/carelink/strings.json +++ b/custom_components/carelink/strings.json @@ -6,8 +6,12 @@ "title": "Setup your Carelink account", "description": "Provide your session token. Please ensure that you enter the 2-letter ISO code corresponding to the country in which your account was registered. If you are logging in with a Carelink Care Partner account, please include the patient ID, which should be the username of the main Carelink account.", "data": { - "country": "[%key:common::config_flow::data::country%]", - "token": "[%key:common::config_flow::data::token%]", + "scan_interval": "[%key:common::config_flow::data::scan_interval%]", + "cl_token": "[%key:common::config_flow::data::cl_token%]", + "cl_refresh_token": "[%key:common::config_flow::data::cl_refresh_token%]", + "cl_client_id": "[%key:common::config_flow::data::cl_client_id%]", + "cl_client_secret": "[%key:common::config_flow::data::cl_client_secret%]", + "cl_mag_identifier": "[%key:common::config_flow::data::cl_mag_identifier%]", "patientId": "[%key:common::config_flow::data::patientId%]", "nightscout_url": "[%key:common::config_flow::data::nightscout_url%]", "nightscout_api": "[%key:common::config_flow::data::nightscout_api%]" diff --git a/custom_components/carelink/translations/de.json b/custom_components/carelink/translations/de.json index e2e59b3..8eb7e49 100644 --- a/custom_components/carelink/translations/de.json +++ b/custom_components/carelink/translations/de.json @@ -11,10 +11,14 @@ "step": { "user": { "title": "Konfigurieren Sie Ihr Carelink-Konto", - "description": "Geben Sie Ihren Session Token ein. Stellen Sie sicher, dass Sie den 2-Buchstaben-ISO-Code eingeben, der dem Land entspricht, in dem Ihr Konto registriert ist. Wenn Sie sich mit einem Carelink Care Partner-Konto anmelden, geben Sie bitte die Patienten-ID an. Diese sollte der Benutzername des primären Carelink-Kontos sein.", + "description": "Führen Sie das externe Script aus, um die Session Informationen zu erhalten. Diese können als Datei in das Verzeichnis der Carelink Integration kopiert werden, oder die Werte der Parameter können in diese Maske eingegben werden. Wenn Sie sich mit einem Carelink Care Partner-Konto anmelden, geben Sie bitte die Patienten-ID an. Diese sollte der Benutzername des primären Carelink-Kontos sein. Der Scan Intervall bestimmt die Zeit in Sekunden bevor die Daten erneut gesammelt werden.", "data": { - "token": "Session Token", - "country": "Land", + "scan_interval": "Scan Intervall in Sekunden (30-300)", + "cl_token": "Access Token (optional)", + "cl_refresh_token": "Refresh Token (optional)", + "cl_client_id": "Client ID (optional)", + "cl_client_secret": "Client Secret (optional)", + "cl_mag_identifier": "Mag Identifier (optional)", "patientId": "Patienten-ID (optional)", "nightscout_url": "Nightscout URL (optional)", "nightscout_api": "Nightscout API Secret (optional)" @@ -22,4 +26,4 @@ } } } -} +} \ No newline at end of file diff --git a/custom_components/carelink/translations/en.json b/custom_components/carelink/translations/en.json index 6489465..4099f74 100644 --- a/custom_components/carelink/translations/en.json +++ b/custom_components/carelink/translations/en.json @@ -11,10 +11,14 @@ "step": { "user": { "title": "Setup your Carelink account", - "description": "Provide your session token. Please ensure that you enter the 2-letter ISO code corresponding to the country in which your account was registered. If you are logging in with a Carelink Care Partner account, please include the patient ID, which should be the username of the main Carelink account.", + "description": "Run the external script to get the session information. These can be copied as a file into the Carelink Integration directory, or the values ??of the parameters can be entered into this mask. If you are logging in with a Carelink Care Partner account, please provide Patient ID. This should be the primary Carelink account username. The Scan Interval determines the number of seconds before the data is collected again.", "data": { - "country": "Country", - "token": "Session Token", + "scan_interval": "Scan Interval in seconds (30-300)", + "cl_token": "Access Token (Optional)", + "cl_refresh_token": "Refresh Token (Optional)", + "cl_client_id": "Client ID (Optional)", + "cl_client_secret": "Client Secret (Optional)", + "cl_mag_identifier": "Mag Identifier (Optional)", "patientId": "Patient ID (Optional)", "nightscout_url": "Nightscout URL (Optional)", "nightscout_api": "Nightscout API Secret (Optional)" diff --git a/custom_components/carelink/translations/fr.json b/custom_components/carelink/translations/fr.json index 4613bf9..e2c6d76 100644 --- a/custom_components/carelink/translations/fr.json +++ b/custom_components/carelink/translations/fr.json @@ -11,10 +11,14 @@ "step": { "user": { "title": "Configurez votre compte Carelink", - "description": "Veuillez entrer votre nom d'utilisateur et votre mot de passe. Assurez-vous d'entrer le code ISO à deux lettres correspondant au pays dans lequel votre compte est enregistré. Si vous vous connectez avec un compte Carelink Care Partner, veuillez indiquer l'ID du patient, qui doit être le nom d'utilisateur du compte Carelink principal.", + "description": "Exécutez le script externe pour obtenir les informations de session. Ceux-ci peuvent être copiés sous forme de fichier dans le répertoire d'intégration Carelink, ou les valeurs des paramètres peuvent être saisies dans ce masque. Si vous vous connectez avec un compte Carelink Care Partner, veuillez fournir l'identifiant du patient. Il doit s'agir du nom d'utilisateur principal du compte Carelink. L'intervalle d'analyse détermine le nombre de secondes avant que les données ne soient à nouveau collectées.", "data": { - "country": "Pays", - "token": "Session Token", + "scan_interval": "Intervalle d'analyse en secondes (30-300)", + "cl_token": "Access Token (facultatif)", + "cl_refresh_token": "Refresh Token (facultatif)", + "cl_client_id": "Client ID (facultatif)", + "cl_client_secret": "Client Secret (facultatif)", + "cl_mag_identifier": "Mag Identifier (facultatif)", "patientId": "ID patient (facultatif)", "nightscout_url": "Nightscout URL (facultatif)", "nightscout_api": "Nightscout API Secret (facultatif)" diff --git a/custom_components/carelink/translations/nl.json b/custom_components/carelink/translations/nl.json index baa5c8a..881f618 100644 --- a/custom_components/carelink/translations/nl.json +++ b/custom_components/carelink/translations/nl.json @@ -11,10 +11,14 @@ "step": { "user": { "title": "Configureer uw Carelink Account", - "description": "Geef uw gebruikersnaam en wachtwoord op. Zorg ervoor dat u de 2-letterige ISO-code invoert die overeenkomt met het land waarin uw account is geregistreerd. Als u zich aanmeldt met een Carelink Care Partner-account, vermeld dan de patiënt-ID, dit moet de gebruikersnaam zijn van het primaire Carelink-account.", + "description": "Voer het externe script uit om de sessie-informatie op te halen. Deze kunnen als bestand naar de Carelink Integration-directory worden gekopieerd, of de waarden van de parameters kunnen in dit masker worden ingevoerd. Als u inlogt met een Carelink Care Partner-account, geef dan uw patiënt-ID op. Dit moet de primaire gebruikersnaam van het Carelink-account zijn. Het Scaninterval bepaalt het aantal seconden voordat de gegevens opnieuw worden verzameld.", "data": { - "country": "Land", - "token": "Session Token", + "scan_interval": "Scaninterval in seconden (30-300)", + "cl_token": "Access Token (Optioneel)", + "cl_refresh_token": "Refresh Token (Optioneel)", + "cl_client_id": "Client ID (Optioneel)", + "cl_client_secret": "Client Secret (Optioneel)", + "cl_mag_identifier": "Mag Identifier (Optioneel)", "patientId": "Patient ID (Optioneel)", "nightscout_url": "Nightscout URL (Optioneel)", "nightscout_api": "Nightscout API Secret (Optioneel)" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4760894 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2023.8.0 +pip>=21.0,<23.2 +ruff==0.0.292 \ No newline at end of file diff --git a/script/bootstrap b/script/bootstrap deleted file mode 100644 index 68f0296..0000000 --- a/script/bootstrap +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Resolve all dependencies that the application requires to run. - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -echo "Installing development dependencies..." -python3 -m pip install wheel --constraint homeassistant/package_constraints.txt -python3 -m pip install colorlog pre-commit $(grep mypy requirements_test.txt) $(grep stdlib-list requirements_test.txt) $(grep tqdm requirements_test.txt) $(grep pipdeptree requirements_test.txt) $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver diff --git a/script/setup b/script/setup deleted file mode 100644 index 210779e..0000000 --- a/script/setup +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# Setups the repository. - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -# Add default vscode settings if not existing -SETTINGS_FILE=./.vscode/settings.json -SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json -if [ ! -f "$SETTINGS_FILE" ]; then - echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." - cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" -fi - -mkdir -p config - -if [ ! -n "$DEVCONTAINER" ];then - python3 -m venv venv - source venv/bin/activate -fi - -script/bootstrap - -pre-commit install -python3 -m pip install -e . --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver - -hass --script ensure_config -c config - -if ! grep -R "logger" config/configuration.yaml >> /dev/null;then -echo " -logger: - default: info - logs: - homeassistant.components.cloud: debug -" >> config/configuration.yaml -fi \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..20366e8 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/scripts/init b/scripts/init new file mode 100644 index 0000000..752d23a --- /dev/null +++ b/scripts/init @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix \ No newline at end of file diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..abe537a --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt \ No newline at end of file diff --git a/utils/carelink_carepartner_api_login.py b/utils/carelink_carepartner_api_login.py new file mode 100644 index 0000000..1b5b985 --- /dev/null +++ b/utils/carelink_carepartner_api_login.py @@ -0,0 +1,254 @@ +############################################################################### +# +# Carelink Carepartner API login +# +# Description: +# +# This program performs the login procedure to the Medtronic Carelink Cloud +# service as implemeted in the Carlink Connect app. On successfull login it +# creates a json file with the resulting login data. The file contains: +# - access_token +# - refresh_token +# - client_id +# - client_secret +# - mag-identifier +# +# Author: +# +# The original code has been implemented by @palmarci (Pal Marci) +# +# Changelog: +# +# 28/12/2023 - Initial version +# +# +# Dependencies: +# +# This script needs the following additional Python packages: +# - OpenSSL +# - seleniumwire +# +############################################################################### +import base64 +import hashlib +import json +import logging +import os +import random +import re +import string +import uuid +from http.client import HTTPConnection +import secrets +from time import sleep +import requests + +import OpenSSL +from seleniumwire import webdriver + + +def setup_logging(): + HTTPConnection.debuglevel = 1 + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True + +def random_b64_str(length): + random_chars = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length + 10)) + base64_string = base64.b64encode(random_chars.encode('utf-8')).decode('utf-8') + return base64_string[:length] + +def random_uuid(): + return str(uuid.UUID(bytes=secrets.token_bytes(16))) + +def random_android_model(): + models = ['SM-G973F', "SM-G988U1", "SM-G981W", "SM-G9600"] + random.shuffle(models) + return models[0] + +def random_device_id(): + return hashlib.sha256(os.urandom(40)).hexdigest() + +def create_csr(keypair, cn, ou, dc, o): + req = OpenSSL.crypto.X509Req() + + #order is not checked + req.get_subject().CN = cn + req.get_subject().OU = ou + req.get_subject().DC = dc + req.get_subject().O = o + + req.set_pubkey(keypair) + req.sign(keypair, 'sha256') + + csr = OpenSSL.crypto.dump_certificate_request(OpenSSL.crypto.FILETYPE_PEM, req) + return csr + +def reformat_csr(csr): + # remove footer & header, re-encode with url safe base64 + csr = csr.decode() + csr = csr.replace("\n", "") + csr = csr.replace("-----BEGIN CERTIFICATE REQUEST-----", "") + csr = csr.replace("-----END CERTIFICATE REQUEST-----", "") + + csr_raw = base64.b64decode(csr.encode()) + csr = base64.urlsafe_b64encode(csr_raw).decode() + return csr + +def do_captcha(url, redirect_url): + driver = webdriver.Firefox() + driver.get(url) + + while True: + for request in driver.requests: + if request.response: + if request.response.status_code == 302: + if "location" in request.response.headers: + location = request.response.headers["location"] + if redirect_url in location: + code = re.search(r"code=(.*)&", location).group(1) + state = re.search(r"state=(.*)", location).group(1) + driver.quit() + return (code, state) + sleep(0.1) + +def resolve_endpoint_config(discovery_url, is_us_region=False): + discover_resp = json.loads(requests.get(discovery_url).text) + sso_url = None + + for c in discover_resp["CP"]: + if c['region'].lower() == "us" and is_us_region: + sso_url = c['SSOConfiguration'] + elif c['region'].lower() == "eu" and not is_us_region: + sso_url = c['SSOConfiguration'] + + if sso_url is None: + raise Exception("Could not get SSO config url") + + sso_config = json.loads(requests.get(sso_url).text) + api_base_url = f"https://{sso_config['server']['hostname']}:{sso_config['server']['port']}/{sso_config['server']['prefix']}" + return sso_config, api_base_url + +def write_datafile(obj, filename): + print("wrote data file") + with open(filename, 'w') as f: + json.dump(obj, f, indent=4) + +def do_login(endpoint_config): + sso_config, api_base_url = endpoint_config + # step 1 initialize + data = { + 'client_id': sso_config['oauth']['client']['client_ids'][0]['client_id'], + "nonce" : random_uuid() + } + headers = { + 'device-id': base64.b64encode(random_device_id().encode()).decode() # this is not used elsewhere? + } + client_init_url = api_base_url + sso_config["mag"]["system_endpoints"]["client_credential_init_endpoint_path"] + client_init_req = requests.post(client_init_url, data=data, headers=headers) + client_init_response = json.loads(client_init_req.text) + + # step 2 authorize + client_code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8') + client_code_verifier = re.sub('[^a-zA-Z0-9]+', '', client_code_verifier) + client_code_challange = hashlib.sha256(client_code_verifier.encode('utf-8')).digest() + client_code_challange = base64.urlsafe_b64encode(client_code_challange).decode('utf-8') + client_code_challange = client_code_challange.replace('=', '') + + client_state = random_b64_str(22) # whats this ? + auth_params = { + 'client_id': client_init_response["client_id"], + 'response_type' : 'code', + 'display' : 'social_login', + 'scope': sso_config["oauth"]["client"]["client_ids"][0]['scope'], + 'redirect_uri': sso_config["oauth"]["client"]["client_ids"][0]['redirect_uri'], + 'code_challenge' : client_code_challange, + 'code_challenge_method': 'S256', + 'state': client_state + } + authorize_url = api_base_url + sso_config["oauth"]["system_endpoints"]["authorization_endpoint_path"] + providers = json.loads(requests.get(authorize_url, params=auth_params).text) # this will redirect + captcha_url = providers["providers"][0]["provider"]["auth_url"] + + # step 3 captcha login and consent + print(f"captcha url: {captcha_url}") + captcha_code, captcha_sso_state = do_captcha(captcha_url, sso_config["oauth"]["client"]["client_ids"][0]['redirect_uri']) + print(f"sso state after captcha: {captcha_sso_state}") + + # step 4 registraton + register_device_id = random_device_id() + client_auth_str = f"{client_init_response['client_id']}:{client_init_response['client_secret']}" + + android_model = random_android_model() + android_model_safe = re.sub(r"[^a-zA-Z0-9]", "", android_model) + keypair = OpenSSL.crypto.PKey() + + # ignoring sso_config['mag']['mobile_sdk']['client_cert_rsa_keybits'], due to the app clamps the minimum size: + # if (i < 2048) + # i = 2048; + keypair.generate_key(OpenSSL.crypto.TYPE_RSA, rsa_keysize) + csr = create_csr(keypair, "socialLogin", register_device_id, android_model_safe, sso_config["oauth"]["client"]["organization"]) + + reg_headers = { + 'device-name': base64.b64encode(android_model.encode()).decode(), + 'authorization' : f"Bearer {captcha_code}", + 'cert-format': 'pem', + 'client-authorization': "Basic " + base64.b64encode(client_auth_str.encode()).decode(), + 'create-session': 'true', + 'code-verifier': client_code_verifier, + 'device-id': base64.b64encode(register_device_id.encode()).decode(), + "redirect-uri": sso_config["oauth"]["client"]["client_ids"][0]['redirect_uri'] + } + csr = reformat_csr(csr) + reg_url = api_base_url + sso_config["mag"]["system_endpoints"]["device_register_endpoint_path"] + reg_req = requests.post(reg_url, headers=reg_headers, data=csr) + if reg_req.status_code != 200: + raise Exception(f'Could not register: {json.loads(reg_req.text)["error_description"]}') + + # TODO: step 5 token + token_req_url = api_base_url + sso_config["oauth"]["system_endpoints"]["token_endpoint_path"] + token_req_data = { + "assertion" : reg_req.headers["id-token"], + "client_id" : client_init_response['client_id'], + "client_secret" : client_init_response['client_secret'], + 'scope': sso_config["oauth"]["client"]["client_ids"][0]['scope'], + "grant_type" : reg_req.headers["id-token-type"] + } + token_req = requests.post(token_req_url, headers={"mag-identifier" : reg_req.headers["mag-identifier"]}, data=token_req_data) + if token_req.status_code != 200: + raise Exception("Could not get token data") + + token_data = json.loads(token_req.text) + print(f"got token data from server") + + token_data["client_id"] = token_req_data["client_id"] + token_data["client_secret"] = token_req_data["client_secret"] + del token_data["scope"] + del token_data["expires_in"] + del token_data["token_type"] + token_data["mag-identifier"] = reg_req.headers["mag-identifier"] + + write_datafile(token_data, logindata_file) + return + +# config +is_debug = False +logindata_file = 'logindata.json' +discovery_url = 'https://clcloud.minimed.eu/connect/carepartner/v6/discover/android/3.1' +rsa_keysize = 2048 + +def main(): + if is_debug: + setup_logging() + + if(os.path.isfile(logindata_file)): + print("Existing file found and deleted.") + os.remove(logindata_file) + + print(f"performing login...") + endpoint_config = resolve_endpoint_config(discovery_url) + do_login(endpoint_config) + +main()