use crate::{
errors::{LibreLinkUpError, Result},
models::{
client::{LibreCgmData, ReadRawResponse, ReadResponse},
common::Connection,
connections::ConnectionsResponse,
countries::CountryConfigResponse,
graph::GraphResponse,
logbook::LogbookResponse,
login::{AccountResponse, LoginArgs, LoginResponse, LoginResponseData, UserResponse},
notifications::NotificationSettingsResponse,
region::Region,
},
utils::{TREND_MAP, map_glucose_data},
};
use reqwest::{Client, header};
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use std::{str::FromStr, sync::Arc};
use tokio::sync::RwLock;
const LOGIN_ENDPOINT: &str = "/llu/auth/login";
const CONNECTIONS_ENDPOINT: &str = "/llu/connections";
const COUNTRY_CONFIG_ENDPOINT: &str = "/llu/config/country";
const USER_ENDPOINT: &str = "/user";
const ACCOUNT_ENDPOINT: &str = "/account";
const NOTIFICATIONS_SETTINGS_ENDPOINT: &str = "/llu/notifications/settings";
type ConnectionFn = Arc<dyn Fn(&[Connection]) -> Option<String> + Send + Sync>;
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub username: String,
pub password: String,
pub api_version: Option<String>,
pub region: Option<Region>,
pub connection_identifier: Option<ConnectionIdentifier>,
}
impl ClientConfig {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: password.into(),
api_version: None,
region: None,
connection_identifier: None,
}
}
#[must_use]
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = Some(version.into());
self
}
#[must_use]
pub fn with_region(mut self, region: Region) -> Self {
self.region = Some(region);
self
}
#[must_use]
pub fn with_connection_identifier(mut self, identifier: ConnectionIdentifier) -> Self {
self.connection_identifier = Some(identifier);
self
}
}
#[derive(Clone)]
pub enum ConnectionIdentifier {
ByName(String),
ByFunction(ConnectionFn),
}
impl std::fmt::Debug for ConnectionIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ByName(name) => write!(f, "ByName({})", name),
Self::ByFunction(_) => write!(f, "ByFunction(<closure>)"),
}
}
}
pub struct LibreLinkUpClient {
config: ClientConfig,
client: Client,
base_url: Arc<RwLock<String>>,
jwt_token: Arc<RwLock<Option<String>>>,
account_id: Arc<RwLock<Option<String>>>,
connection_id: Arc<RwLock<Option<String>>>,
}
impl LibreLinkUpClient {
pub fn new(config: ClientConfig) -> Result<Self> {
if config.username.trim().is_empty() {
return Err(LibreLinkUpError::AuthFailed(
"username must not be empty".to_string(),
));
}
if config.password.is_empty() {
return Err(LibreLinkUpError::AuthFailed(
"password must not be empty".to_string(),
));
}
let version = config
.api_version
.clone()
.unwrap_or_else(|| "4.16.0".to_string());
let region = config.region.unwrap_or_default();
let base_url_str = region.base_url().to_string();
let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, "Mozilla/5.0 (iPhone; CPU OS 17_4.1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/17.4.1 Mobile/10A5355d Safari/8536.25".parse().unwrap());
headers.insert(header::ACCEPT, "application/json".parse().unwrap());
headers.insert("accept-encoding", "gzip".parse().unwrap());
headers.insert("cache-control", "no-cache".parse().unwrap());
headers.insert("connection", "Keep-Alive".parse().unwrap());
headers.insert(
header::CONTENT_TYPE,
"application/json;charset=UTF-8".parse().unwrap(),
);
headers.insert("product", "llu.ios".parse().unwrap());
headers.insert("version", version.parse().unwrap());
headers.insert("accept-language", "en-US".parse().unwrap());
let client: Client = Client::builder()
.default_headers(headers)
.gzip(true)
.build()?;
Ok(Self {
config,
client,
base_url: Arc::new(RwLock::new(base_url_str)),
jwt_token: Arc::new(RwLock::new(None)),
account_id: Arc::new(RwLock::new(None)),
connection_id: Arc::new(RwLock::new(None)),
})
}
pub fn simple(
username: impl Into<String>,
password: impl Into<String>,
region: Option<String>,
) -> Result<Self> {
let username = username.into();
let password = password.into();
if username.trim().is_empty() {
return Err(LibreLinkUpError::AuthFailed(
"username must not be empty".to_string(),
));
}
if password.is_empty() {
return Err(LibreLinkUpError::AuthFailed(
"password must not be empty".to_string(),
));
}
let region_enum = region
.as_ref()
.and_then(|s| Region::from_str(s).ok())
.or(Some(Region::default()));
Self::new(ClientConfig {
username,
password,
api_version: None,
region: region_enum,
connection_identifier: None,
})
}
async fn login(&self) -> Result<LoginResponse> {
let base_url = self.base_url.read().await.clone();
let url = format!("{}{}", base_url, LOGIN_ENDPOINT);
let login_args = LoginArgs {
username: "alikoheil2004@gmail.com".to_string(),
password: "l$KNU00s75WEX%".to_string(),
};
let response = self.client.post(&url).json(&login_args).send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response".to_string());
return Err(LibreLinkUpError::InvalidResponse(format!(
"Login failed - HTTP {}: {}",
status, text
)));
}
let text = response.text().await?;
let login_response: LoginResponse = serde_json::from_str(&text).map_err(|e| {
LibreLinkUpError::InvalidResponse(format!("Failed to parse JSON: {}", e))
})?;
if let LoginResponseData::Locked(locked_data) = &login_response.data {
return Err(LibreLinkUpError::AccountLocked(locked_data.data.lockout));
}
if login_response.status == 2 {
return Err(LibreLinkUpError::BadCredentials);
}
if login_response.status == 4 {
let component_name = match &login_response.data {
LoginResponseData::Step(step_data) => step_data.step.component_name.clone(),
_ => "unknown".to_string(),
};
return Err(LibreLinkUpError::AdditionalActionRequired(component_name));
}
if let LoginResponseData::Redirect(redirect_data) = &login_response.data
&& redirect_data.redirect
{
return self.handle_redirect(redirect_data.region.clone()).await;
}
if let LoginResponseData::Complete(data) = &login_response.data {
*self.jwt_token.write().await = Some(data.auth_ticket.token.clone());
*self.account_id.write().await = Some(data.user.id.clone());
}
Ok(login_response)
}
async fn handle_redirect(&self, region: String) -> Result<LoginResponse> {
let region_enum = Region::from_str(®ion).unwrap();
let region_url = region_enum.base_url().to_string();
*self.base_url.write().await = region_url;
Box::pin(self.login()).await
}
async fn authenticated_request<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
if self.jwt_token.read().await.is_none() {
self.login().await?;
}
match self.try_request(path).await {
Ok(response) => Ok(response),
Err(_) => {
self.login().await?;
self.try_request(path).await
}
}
}
async fn try_request<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let base_url = self.base_url.read().await.clone();
let url = format!("{}{}", base_url, path);
let jwt_token = self.jwt_token.read().await.clone();
let account_id = self.account_id.read().await.clone();
let mut request = self.client.get(&url);
if let Some(token) = jwt_token {
request = request.header(header::AUTHORIZATION, format!("Bearer {}", token));
}
if let Some(id) = account_id {
let mut hasher = Sha256::new();
hasher.update(id.as_bytes());
let hashed_id = format!("{:x}", hasher.finalize());
request = request.header("account-id", hashed_id);
}
let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response".to_string());
return Err(LibreLinkUpError::InvalidResponse(format!(
"request to '{}' failed - HTTP {}: {}",
path, status, text
)));
}
let text = response.text().await?;
serde_json::from_str(&text).map_err(|e| {
LibreLinkUpError::InvalidResponse(format!("failed to parse JSON for '{}': {}", path, e))
})
}
async fn unauthenticated_get<T: DeserializeOwned>(
&self,
url: &str,
path_label: &str,
) -> Result<T> {
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response".to_string());
return Err(LibreLinkUpError::InvalidResponse(format!(
"request to '{}' failed - HTTP {}: {}",
path_label, status, body
)));
}
let body: String = response.text().await?;
serde_json::from_str(&body).map_err(|e| {
LibreLinkUpError::InvalidResponse(format!(
"failed to parse JSON for '{}': {}",
path_label, e
))
})
}
async fn get_connections(&self) -> Result<ConnectionsResponse> {
self.authenticated_request(CONNECTIONS_ENDPOINT).await
}
pub async fn get_user(&self) -> Result<UserResponse> {
self.authenticated_request(USER_ENDPOINT).await
}
pub async fn get_account(&self) -> Result<AccountResponse> {
self.authenticated_request(ACCOUNT_ENDPOINT).await
}
pub async fn get_logbook(&self, patient_id: &str) -> Result<LogbookResponse> {
let path = format!("{}/{}/logbook", CONNECTIONS_ENDPOINT, patient_id);
self.authenticated_request(&path).await
}
pub async fn get_notification_settings(
&self,
connection_id: &str,
) -> Result<NotificationSettingsResponse> {
let path = format!("{}/{}", NOTIFICATIONS_SETTINGS_ENDPOINT, connection_id);
self.authenticated_request(&path).await
}
pub async fn get_country_config(
&self,
country: &str,
version: Option<&str>,
) -> Result<CountryConfigResponse> {
let version = version.unwrap_or_else(|| self.config.api_version.as_deref().unwrap_or("4.16.0"));
let url = format!(
"{}{}?country={}&version={}",
Region::Global.base_url(),
COUNTRY_CONFIG_ENDPOINT,
country,
version,
);
self.unauthenticated_get(&url, COUNTRY_CONFIG_ENDPOINT)
.await
}
fn get_connection_id(&self, connections: &[Connection]) -> Result<String> {
match &self.config.connection_identifier {
Some(ConnectionIdentifier::ByName(name)) => {
let connection = connections
.iter()
.find(|c| {
format!("{} {}", c.first_name, c.last_name).to_lowercase()
== name.to_lowercase()
})
.ok_or_else(|| LibreLinkUpError::ConnectionNotFound(name.clone()))?;
Ok(connection.patient_id.clone())
}
Some(ConnectionIdentifier::ByFunction(func)) => {
func(connections).ok_or(LibreLinkUpError::ConnectionFunctionFailed)
}
None => {
connections
.first()
.map(|c| c.patient_id.clone())
.ok_or(LibreLinkUpError::NoConnections)
}
}
}
pub async fn read_raw(&self) -> Result<ReadRawResponse> {
let connection_id = if let Some(id) = self.connection_id.read().await.clone() {
id
} else {
let connections = self.get_connections().await?;
if connections.data.is_empty() {
return Err(LibreLinkUpError::NoConnections);
}
let id = self.get_connection_id(&connections.data)?;
*self.connection_id.write().await = Some(id.clone());
id
};
let path = format!("{}/{}/graph", CONNECTIONS_ENDPOINT, connection_id);
let graph_response: GraphResponse = self.authenticated_request(&path).await?;
Ok(ReadRawResponse {
connection: graph_response.data.connection,
active_sensors: graph_response.data.active_sensors,
graph_data: graph_response.data.graph_data,
})
}
pub async fn read(&self) -> Result<ReadResponse> {
let raw = self.read_raw().await?;
Ok(ReadResponse {
current: map_glucose_data(&raw.connection.glucose_measurement),
history: raw.graph_data.iter().map(map_glucose_data).collect(),
})
}
pub async fn read_averaged<F>(
&self,
amount: usize,
mut callback: F,
interval_ms: u64,
) -> Result<tokio::task::JoinHandle<()>>
where
F: FnMut(LibreCgmData, Vec<LibreCgmData>, Vec<LibreCgmData>) + Send + 'static,
{
let client = Self::new(self.config.clone())?;
let handle = tokio::spawn(async move {
let mut memory: Vec<LibreCgmData> = Vec::new();
let mut interval =
tokio::time::interval(tokio::time::Duration::from_millis(interval_ms));
loop {
interval.tick().await;
if let Ok(read_response) = client.read().await {
let current = read_response.current;
let history = read_response.history;
if !memory.iter().any(|m| m.timestamp == current.timestamp) {
memory.push(current.clone());
}
if memory.len() >= amount {
let avg_value =
memory.iter().map(|m| m.value).sum::<f64>() / memory.len() as f64;
let trend_indices: Vec<usize> = memory
.iter()
.filter_map(|m| TREND_MAP.iter().position(|&t| t == m.trend))
.collect();
let avg_trend_idx = if !trend_indices.is_empty() {
(trend_indices.iter().sum::<usize>() as f64
/ trend_indices.len() as f64)
.round() as usize
} else {
3 };
let avg_trend = TREND_MAP
.get(avg_trend_idx)
.copied()
.unwrap_or(TREND_MAP[3]);
let averaged = LibreCgmData {
value: avg_value.round(),
is_high: current.is_high,
is_low: current.is_low,
trend: avg_trend,
timestamp: current.timestamp,
};
let collected = std::mem::take(&mut memory);
callback(averaged, collected, history);
}
}
}
});
Ok(handle)
}
}