From 4888426034cc05fff39263bf1024f758ac9eaa2e Mon Sep 17 00:00:00 2001 From: Adam Bovill Date: Sat, 7 Jun 2025 05:37:32 +0000 Subject: [PATCH] feat: Add EU region support for Tandem t:connect API Add comprehensive support for European Tandem t:connect servers alongside existing US support. Features: - Region parameter in TandemSourceApi (default: US, supports: US/EU) - EU-specific API endpoints and client ID configuration - Region-aware credential caching to prevent cross-region conflicts - Command line --region flag and TCONNECT_REGION environment variable - Full backward compatibility (defaults to US region) Technical Changes: - Separated common SSO URLs from region-specific service URLs - Added region validation and URL property methods - Enhanced credential cache with region isolation - Updated CLI argument parsing and configuration system - Added comprehensive logging for region selection Testing: - Verified US region backward compatibility - Successfully tested EU authentication and data retrieval - Processed real EU pump data (1500+ events, 39KB) - Validated all event types: basal, bolus, CGM, user modes, alarms - Confirmed Nightscout integration compatibility This enables EU Tandem pump users to sync their data using: --region EU or TCONNECT_REGION=EU --- tconnectsync/__init__.py | 7 +- tconnectsync/api/__init__.py | 7 +- tconnectsync/api/tandemsource.py | 82 ++++++++++++++++--- tconnectsync/secret.py | 1 + tconnectsync/sync/tandemsource/cli_helpers.py | 4 +- tconnectsync/util/cli.py | 4 +- 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/tconnectsync/__init__.py b/tconnectsync/__init__.py index c0ee976..e8e55db 100644 --- a/tconnectsync/__init__.py +++ b/tconnectsync/__init__.py @@ -25,6 +25,7 @@ try: from .secret import ( TCONNECT_EMAIL, TCONNECT_PASSWORD, + TCONNECT_REGION, NS_URL, NS_SECRET, NS_SKIP_TLS_VERIFY, @@ -54,6 +55,7 @@ def parse_args(*args, **kwargs): parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.') parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.') parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=False, help='FOR TESTING: Use Tandem Source') + parser.add_argument('--region', dest='region', type=str, choices=['US', 'EU'], default=None, help='Tandem t:connect server region (US or EU). If not specified, uses TCONNECT_REGION from configuration or defaults to US.') return parser.parse_args(*args, **kwargs) @@ -85,6 +87,8 @@ def main(*args, **kwargs): if time_end < time_start: raise Exception('time_start must be before time_end') + # Determine region: command line arg takes precedence, then config, then default to US + region = args.region if args.region else TCONNECT_REGION if TCONNECT_EMAIL == 'email@email.com': logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.') @@ -98,7 +102,7 @@ def main(*args, **kwargs): else: logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.') - tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD) + tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, region) nightscout = NightscoutApi(NS_URL, NS_SECRET, skip_verify=NS_SKIP_TLS_VERIFY, ignore_conn_errors=NS_IGNORE_CONN_ERRORS) @@ -110,6 +114,7 @@ def main(*args, **kwargs): logging.info("You may notice different behavior compared to older versions which utilized t:connect data sources.") logging.info("To report a bug or to get help, see https://github.com/jwoglom/tconnectsync/issues") + logging.info(f"Using Tandem t:connect region: {region}") logging.info("Enabled features: " + ", ".join(args.features)) if args.check_login: diff --git a/tconnectsync/api/__init__.py b/tconnectsync/api/__init__.py index dead215..e6ff587 100644 --- a/tconnectsync/api/__init__.py +++ b/tconnectsync/api/__init__.py @@ -13,9 +13,10 @@ class TConnectApi: email = None password = None - def __init__(self, email, password): + def __init__(self, email, password, region='US'): self.email = email self.password = password + self.region = region self._ciq = None self._ws2 = None self._android = None @@ -27,9 +28,9 @@ class TConnectApi: if self._tandemsource and not self._tandemsource.needs_relogin(): return self._tandemsource - logger.debug("Instantiating new TandemSourceApi") + logger.debug(f"Instantiating new TandemSourceApi for region {self.region}") - self._tandemsource = TandemSourceApi(self.email, self.password) + self._tandemsource = TandemSourceApi(self.email, self.password, self.region) return self._tandemsource diff --git a/tconnectsync/api/tandemsource.py b/tconnectsync/api/tandemsource.py index 10f9a98..d446261 100644 --- a/tconnectsync/api/tandemsource.py +++ b/tconnectsync/api/tandemsource.py @@ -24,23 +24,73 @@ from ..eventparser.generic import Events, decode_raw_events, EVENT_LEN logger = logging.getLogger(__name__) class TandemSourceApi: + # Common URLs that are shared between regions LOGIN_PAGE_URL = 'https://sso.tandemdiabetes.com/' - LOGIN_API_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/login' TDC_AUTH_CALLBACK_URL = 'https://sso.tandemdiabetes.com/auth/callback' - TDC_OAUTH_AUTHORIZE_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize' - TDC_OIDC_JWKS_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks' - TDC_OIDC_ISSUER = 'https://tdcservices.tandemdiabetes.com/accounts/api' # openid_config['issuer'] - TDC_OIDC_CLIENT_ID = '0oa27ho9tpZE9Arjy4h7' - SOURCE_URL = 'https://source.tandemdiabetes.com/' + + # US Region URLs (default) + _US_URLS = { + 'LOGIN_API_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/login', + 'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize', + 'TDC_OIDC_JWKS_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks', + 'TDC_OIDC_ISSUER': 'https://tdcservices.tandemdiabetes.com/accounts/api', + 'TDC_OIDC_CLIENT_ID': '0oa27ho9tpZE9Arjy4h7', + 'SOURCE_URL': 'https://source.tandemdiabetes.com/', + 'REDIRECT_URI': 'https://sso.tandemdiabetes.com/auth/callback', + 'TOKEN_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token', + 'AUTHORIZATION_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize' + } + + # EU Region URLs + _EU_URLS = { + 'LOGIN_API_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/login', + 'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/oauth2/v1/authorize', + 'TDC_OIDC_JWKS_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks', + 'TDC_OIDC_ISSUER': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api', + 'TDC_OIDC_CLIENT_ID': '1519e414-eeec-492e-8c5e-97bea4815a10', + 'SOURCE_URL': 'https://source.eu.tandemdiabetes.com/', + 'REDIRECT_URI': 'https://source.eu.tandemdiabetes.com/authorize/callback', + 'TOKEN_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/token', + 'AUTHORIZATION_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/authorize' + } - - def __init__(self, email, password): + def __init__(self, email, password, region='US'): + self.region = region.upper() + if self.region not in ['US', 'EU']: + raise ValueError(f"Invalid region '{region}'. Must be 'US' or 'EU'.") + + self._region_urls = self._US_URLS if self.region == 'US' else self._EU_URLS + self.login(email, password) self._email = email self._password = password + @property + def LOGIN_API_URL(self): + return self._region_urls['LOGIN_API_URL'] + + @property + def TDC_OAUTH_AUTHORIZE_URL(self): + return self._region_urls['TDC_OAUTH_AUTHORIZE_URL'] + + @property + def TDC_OIDC_JWKS_URL(self): + return self._region_urls['TDC_OIDC_JWKS_URL'] + + @property + def TDC_OIDC_ISSUER(self): + return self._region_urls['TDC_OIDC_ISSUER'] + + @property + def TDC_OIDC_CLIENT_ID(self): + return self._region_urls['TDC_OIDC_CLIENT_ID'] + + @property + def SOURCE_URL(self): + return self._region_urls['SOURCE_URL'] + def login(self, email, password): - logger.info("Logging in to TandemSourceApi...") + logger.info(f"Logging in to TandemSourceApi ({self.region} region)...") if self.try_load_cached_creds(email): logger.info("Successfully used cached credentials") return True @@ -70,11 +120,10 @@ class TandemSourceApi: # oidc client_id = self.TDC_OIDC_CLIENT_ID - redirect_uri = 'https://sso.tandemdiabetes.com/auth/callback' # must be an allowlisted URI + redirect_uri = self._region_urls['REDIRECT_URI'] scope = 'openid profile email' - token_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token' #openid_config['token_endpoint'] - + token_endpoint = self._region_urls['TOKEN_ENDPOINT'] def generate_code_verifier(): """Generates a high-entropy code verifier.""" @@ -91,7 +140,7 @@ class TandemSourceApi: code_verifier = generate_code_verifier() code_challenge = generate_code_challenge(code_verifier) - authorization_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize' #openid_config['authorization_endpoint'] + authorization_endpoint = self._region_urls['AUTHORIZATION_ENDPOINT'] oidc_step1_params = { 'client_id': client_id, @@ -224,6 +273,12 @@ class TandemSourceApi: logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping") return False + # Check if cached region matches current region + cached_region = _saved_blob.get('cache_creds_region', 'US') # Default to US for backward compatibility + if cached_region != self.region: + logger.warning(f"Cached credentials are for a different region ({cached_region} in cache, but using {self.region}), skipping") + return False + at_expiry = _saved_blob['accessTokenExpiresAt'] if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp: logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping") @@ -278,6 +333,7 @@ class TandemSourceApi: 'cache_creds_version': 1.0, 'cache_creds_saved_at': arrow.get(), 'cache_creds_email': email, + 'cache_creds_region': self.region, # Store the region in cache 'jwtData': self.jwtData, 'pumperId': self.pumperId, 'accountId': self.accountId, diff --git a/tconnectsync/secret.py b/tconnectsync/secret.py index 96433b2..9b85386 100644 --- a/tconnectsync/secret.py +++ b/tconnectsync/secret.py @@ -41,6 +41,7 @@ def get_bool(name, default): TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com') TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password') +TCONNECT_REGION = get_one_of('TCONNECT_REGION', 'US', ['US', 'EU']) PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111')) diff --git a/tconnectsync/sync/tandemsource/cli_helpers.py b/tconnectsync/sync/tandemsource/cli_helpers.py index 1363b3d..b101be6 100644 --- a/tconnectsync/sync/tandemsource/cli_helpers.py +++ b/tconnectsync/sync/tandemsource/cli_helpers.py @@ -10,8 +10,8 @@ import logging logger = logging.getLogger(__name__) -def fetch_oneshot(username, password, time_start=None, time_end=None): - tconnect = TConnectApi(username, password) +def fetch_oneshot(username, password, time_start=None, time_end=None, region='US'): + tconnect = TConnectApi(username, password, region) if not time_start and not time_end: time_end = datetime.datetime.now() time_start = time_end - datetime.timedelta(days=1) diff --git a/tconnectsync/util/cli.py b/tconnectsync/util/cli.py index 57708ce..78dfa0e 100644 --- a/tconnectsync/util/cli.py +++ b/tconnectsync/util/cli.py @@ -16,5 +16,5 @@ Returns a TConnectApi object with default secret parameters. """ def get_api(): from ..api import TConnectApi - from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD - return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD) \ No newline at end of file + from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION + return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION) \ No newline at end of file