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
This commit is contained in:
Adam Bovill
2025-06-07 22:48:43 -04:00
committed by James Woglom
parent 803b15b886
commit 4888426034
6 changed files with 84 additions and 21 deletions
+6 -1
View File
@@ -25,6 +25,7 @@ try:
from .secret import ( from .secret import (
TCONNECT_EMAIL, TCONNECT_EMAIL,
TCONNECT_PASSWORD, TCONNECT_PASSWORD,
TCONNECT_REGION,
NS_URL, NS_URL,
NS_SECRET, NS_SECRET,
NS_SKIP_TLS_VERIFY, 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('--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('--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('--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) return parser.parse_args(*args, **kwargs)
@@ -85,6 +87,8 @@ def main(*args, **kwargs):
if time_end < time_start: if time_end < time_start:
raise Exception('time_start must be before time_end') 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': if TCONNECT_EMAIL == 'email@email.com':
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.') logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
@@ -98,7 +102,7 @@ def main(*args, **kwargs):
else: else:
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.') 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) 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("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("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)) logging.info("Enabled features: " + ", ".join(args.features))
if args.check_login: if args.check_login:
+4 -3
View File
@@ -13,9 +13,10 @@ class TConnectApi:
email = None email = None
password = None password = None
def __init__(self, email, password): def __init__(self, email, password, region='US'):
self.email = email self.email = email
self.password = password self.password = password
self.region = region
self._ciq = None self._ciq = None
self._ws2 = None self._ws2 = None
self._android = None self._android = None
@@ -27,9 +28,9 @@ class TConnectApi:
if self._tandemsource and not self._tandemsource.needs_relogin(): if self._tandemsource and not self._tandemsource.needs_relogin():
return self._tandemsource 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 return self._tandemsource
+68 -12
View File
@@ -24,23 +24,73 @@ from ..eventparser.generic import Events, decode_raw_events, EVENT_LEN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TandemSourceApi: class TandemSourceApi:
# Common URLs that are shared between regions
LOGIN_PAGE_URL = 'https://sso.tandemdiabetes.com/' 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_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, 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
def __init__(self, email, password):
self.login(email, password) self.login(email, password)
self._email = email self._email = email
self._password = password 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): 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): if self.try_load_cached_creds(email):
logger.info("Successfully used cached credentials") logger.info("Successfully used cached credentials")
return True return True
@@ -70,11 +120,10 @@ class TandemSourceApi:
# oidc # oidc
client_id = self.TDC_OIDC_CLIENT_ID 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' 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(): def generate_code_verifier():
"""Generates a high-entropy code verifier.""" """Generates a high-entropy code verifier."""
@@ -91,7 +140,7 @@ class TandemSourceApi:
code_verifier = generate_code_verifier() code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(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 = { oidc_step1_params = {
'client_id': client_id, '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") logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping")
return False 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'] at_expiry = _saved_blob['accessTokenExpiresAt']
if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp: if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp:
logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping") logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping")
@@ -278,6 +333,7 @@ class TandemSourceApi:
'cache_creds_version': 1.0, 'cache_creds_version': 1.0,
'cache_creds_saved_at': arrow.get(), 'cache_creds_saved_at': arrow.get(),
'cache_creds_email': email, 'cache_creds_email': email,
'cache_creds_region': self.region, # Store the region in cache
'jwtData': self.jwtData, 'jwtData': self.jwtData,
'pumperId': self.pumperId, 'pumperId': self.pumperId,
'accountId': self.accountId, 'accountId': self.accountId,
+1
View File
@@ -41,6 +41,7 @@ def get_bool(name, default):
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com') TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password') 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')) PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
@@ -10,8 +10,8 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def fetch_oneshot(username, password, time_start=None, time_end=None): def fetch_oneshot(username, password, time_start=None, time_end=None, region='US'):
tconnect = TConnectApi(username, password) tconnect = TConnectApi(username, password, region)
if not time_start and not time_end: if not time_start and not time_end:
time_end = datetime.datetime.now() time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=1) time_start = time_end - datetime.timedelta(days=1)
+2 -2
View File
@@ -16,5 +16,5 @@ Returns a TConnectApi object with default secret parameters.
""" """
def get_api(): def get_api():
from ..api import TConnectApi from ..api import TConnectApi
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD) return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION)