mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
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:
committed by
James Woglom
parent
803b15b886
commit
4888426034
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
# US Region URLs (default)
|
||||||
TDC_OIDC_ISSUER = 'https://tdcservices.tandemdiabetes.com/accounts/api' # openid_config['issuer']
|
_US_URLS = {
|
||||||
TDC_OIDC_CLIENT_ID = '0oa27ho9tpZE9Arjy4h7'
|
'LOGIN_API_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/login',
|
||||||
SOURCE_URL = 'https://source.tandemdiabetes.com/'
|
'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'):
|
||||||
def __init__(self, email, password):
|
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.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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user