Skip to content

Commit

Permalink
Merge pull request #138 from timoschlueter/release/2.6.0
Browse files Browse the repository at this point in the history
Release 2.6.0
  • Loading branch information
timoschlueter authored May 14, 2024
2 parents 86fbe3d + 11119d0 commit beb0566
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 82 deletions.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.git*
README.md
22 changes: 16 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
FROM node:20-bookworm
LABEL description="Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout"
FROM node:20-bookworm-slim AS build-stage

# Create app directory
RUN mkdir -p /usr/src/app
Expand All @@ -13,9 +12,20 @@ RUN npm install
COPY . /usr/src/app

# Run tests
RUN npm run test
RUN npm run test ; \
rm -r tests coverage

# Compile
RUN npm run build

RUN rm -r tests
RUN rm -r coverage
# Remove devel-only dependencies
RUN npm prune --omit dev

FROM node:20-bookworm-slim
LABEL description="Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout"

COPY --from=build-stage /usr/src/app /usr/src/app

WORKDIR /usr/src/app

CMD [ "npm", "start" ]
CMD [ "npm", "run", "start-heroku" ]
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nightscout-librelink-up",
"version": "2.5.1",
"version": "2.6.0",
"description": "Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout",
"main": "dist/index.js",
"scripts": {
Expand Down
103 changes: 84 additions & 19 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,87 @@
function readConfig() {
const requiredEnvs = ['NIGHTSCOUT_API_TOKEN', 'NIGHTSCOUT_URL'];
for (let envName of requiredEnvs) {
if (!process.env[envName]) {
throw Error(`Required environment variable ${envName} is not set`);
}
}

const protocol =
process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true' ? 'http://' : 'https://';
const url = new URL(protocol + process.env.NIGHTSCOUT_URL);

return {
nightscoutApiToken: process.env.NIGHTSCOUT_API_TOKEN as string,
nightscoutBaseUrl: url.toString(),

nightscoutApiV3: process.env.NIGHTSCOUT_API_V3 === 'true',
nightscoutDevice: process.env.DEVICE_NAME || 'nightscout-librelink-up',
};
import {LLU_API_ENDPOINTS} from './constants/llu-api-endpoints';

function readConfig()
{
let requiredEnvs: string[] = [];

if (!isTest())
{
requiredEnvs = [
'NIGHTSCOUT_API_TOKEN',
'NIGHTSCOUT_URL',
'LINK_UP_USERNAME',
'LINK_UP_PASSWORD',
];
}

for (let envName of requiredEnvs)
{
if (!process.env[envName])
{
exitLog(`Required environment variable ${envName} is not set`)
}
}

if (process.env.LOG_LEVEL)
{
if (!['info', 'debug'].includes(process.env.LOG_LEVEL.toLowerCase()))
{
exitLog(`LOG_LEVEL should be either 'info' or 'debug', but got '${process.env.LOG_LEVEL}'`);
}
}

if (process.env.LINK_UP_REGION)
{
if (!LLU_API_ENDPOINTS.hasOwnProperty(process.env.LINK_UP_REGION))
{
exitLog(`LINK_UP_REGION should be one of ${Object.keys(LLU_API_ENDPOINTS)}, but got ${process.env.LINK_UP_REGION}`);
}
}

if (process.env.LINK_UP_TIME_INTERVAL)
{
if (isNaN(parseInt(process.env.LINK_UP_TIME_INTERVAL)))
{
exitLog(`LINK_UP_TIME_INTERVAL expected to be an integer, but got '${process.env.LINK_UP_TIME_INTERVAL}'`);
}
}

const protocol =
process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true' ? 'http://' : 'https://';
const url = new URL(protocol + process.env.NIGHTSCOUT_URL);

return {
nightscoutApiToken: process.env.NIGHTSCOUT_API_TOKEN as string,
nightscoutBaseUrl: url.toString(),
linkUpUsername: process.env.LINK_UP_USERNAME as string,
linkUpPassword: process.env.LINK_UP_PASSWORD as string,

logLevel: process.env.LOG_LEVEL || 'info',
singleShot: process.env.SINGLE_SHOT === 'true',

nightscoutApiV3: process.env.NIGHTSCOUT_API_V3 === 'true',
nightscoutDisableHttps: process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true',
nightscoutDevice: process.env.DEVICE_NAME || 'nightscout-librelink-up',

linkUpRegion: process.env.LINK_UP_REGION || 'EU',
linkUpTimeInterval: Number(process.env.LINK_UP_TIME_INTERVAL) || 5,
linkUpConnection: process.env.LINK_UP_CONNECTION as string,
};
}

function exitLog(msg: string): void
{
console.log(msg);

if (!isTest())
{
process.exit(1);
}
}

function isTest(): boolean
{
return typeof jest !== "undefined";
}

export default readConfig;
89 changes: 43 additions & 46 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,25 @@ import {Agent as HttpAgent} from "node:http";
import {Agent as HttpsAgent} from "node:https";
import * as crypto from "crypto";

// Generate new Cyphers for stealth mode in order to bypass SSL fingerprinting used by Cloudflare.
// The new Cyphers are then used in the HTTPS Agent for Axios.
const defaultCyphers: Array<string> = crypto.constants.defaultCipherList.split(":");
const stealthCyphers: Array<string> = defaultCyphers.slice(0, 3);
// Generate new Ciphers for stealth mode in order to bypass SSL fingerprinting used by Cloudflare.
// The new Ciphers are then used in the HTTPS Agent for Axios.
const defaultCiphers: Array<string> = crypto.constants.defaultCipherList.split(":");
const stealthCiphers: Array<string> = [
defaultCiphers[0],
defaultCiphers[2],
defaultCiphers[1],
...defaultCiphers.slice(3)
];

const stealthHttpsAgent: HttpsAgent = new HttpsAgent({
ciphers: stealthCyphers.join(":")
ciphers: stealthCiphers.join(":")
});

// Create a new CookieJar and HttpCookieAgent for Axios to handle cookies.
const jar: CookieJar = new CookieJar();
const cookieAgent: HttpAgent = new HttpCookieAgent({cookies: {jar}})

const config = readConfig();
let config = readConfig();

const {combine, timestamp, printf} = format;

Expand All @@ -51,50 +57,34 @@ const logger = createLogger({
logFormat
),
transports: [
new transports.Console({level: process.env.LOG_LEVEL || "info"}),
new transports.Console({level: config.logLevel}),
]
});

axios.interceptors.response.use(response =>
{
return response;
}, error =>
{
if (error.response)
{
logger.error(JSON.stringify(error.response.data));
}
else
axios.interceptors.response.use(
response => response,
error =>
{
logger.error(error.message);
if (error.response)
{
logger.error(JSON.stringify(error.response.data));
}
else
{
logger.error(error.message);
}
return error;
}
return error;
});
);

const USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1";

/**
* LibreLink Up Credentials
*/
const LINK_UP_USERNAME = process.env.LINK_UP_USERNAME;
const LINK_UP_PASSWORD = process.env.LINK_UP_PASSWORD;
const 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";

/**
* LibreLink Up API Settings (Don't change this unless you know what you are doing)
*/
const LIBRE_LINK_UP_VERSION = "4.7.0";
const LIBRE_LINK_UP_VERSION = "4.10.0";
const LIBRE_LINK_UP_PRODUCT = "llu.ios";
const LINK_UP_REGION = process.env.LINK_UP_REGION || "EU";
const LIBRE_LINK_UP_URL = getLibreLinkUpUrl(LINK_UP_REGION);

function getLibreLinkUpUrl(region: string): string
{
if (LLU_API_ENDPOINTS.hasOwnProperty(region))
{
return LLU_API_ENDPOINTS[region];
}
return LLU_API_ENDPOINTS.EU;
}
const LIBRE_LINK_UP_URL = LLU_API_ENDPOINTS[config.linkUpRegion];

/**
* last known authTicket
Expand All @@ -108,13 +98,13 @@ const libreLinkUpHttpHeaders: LibreLinkUpHttpHeaders = {
"product": LIBRE_LINK_UP_PRODUCT
}

if (process.env.SINGLE_SHOT === "true")
if (config.singleShot)
{
main().then();
}
else
{
const schedule = "*/" + (process.env.LINK_UP_TIME_INTERVAL || 5) + " * * * *";
const schedule = `*/${config.linkUpTimeInterval} * * * *`;
logger.info("Starting cron schedule: " + schedule)
cron.schedule(schedule, () =>
{
Expand Down Expand Up @@ -150,22 +140,24 @@ async function main(): Promise<void>

export async function login(): Promise<AuthTicket | null>
{
config = readConfig()

try
{
const url = "https://" + LIBRE_LINK_UP_URL + "/llu/auth/login"
const response: { data: LoginResponse } = await axios.post(
url,
{
email: LINK_UP_USERNAME,
password: LINK_UP_PASSWORD,
email: config.linkUpUsername,
password: config.linkUpPassword,
},
{
headers: libreLinkUpHttpHeaders,
withCredentials: true, // Enable automatic cookie handling
httpAgent: cookieAgent,
httpsAgent: stealthHttpsAgent
});

try
{
if (response.data.status !== 0)
Expand Down Expand Up @@ -197,6 +189,8 @@ export async function login(): Promise<AuthTicket | null>

export async function getGlucoseMeasurements(): Promise<GraphData | null>
{
config = readConfig()

try
{
const connectionId = await getLibreLinkUpConnection();
Expand Down Expand Up @@ -226,6 +220,8 @@ export async function getGlucoseMeasurements(): Promise<GraphData | null>

export async function getLibreLinkUpConnection(): Promise<string | null>
{
config = readConfig()

try
{
const url = "https://" + LIBRE_LINK_UP_URL + "/llu/connections"
Expand Down Expand Up @@ -255,14 +251,15 @@ export async function getLibreLinkUpConnection(): Promise<string | null>

dumpConnectionData(connectionData);

if (!process.env.LINK_UP_CONNECTION)
if (!config.linkUpConnection)
{
logger.warn("You did not specify a Patient-ID in the LINK_UP_CONNECTION environment variable.");
logPickedUpConnection(connectionData[0]);
return connectionData[0].patientId;
}

const connection = connectionData.filter(connectionEntry => connectionEntry.patientId === process.env.LINK_UP_CONNECTION)[0];
const connection = connectionData.filter(connectionEntry => connectionEntry.patientId === config.linkUpConnection)[0];

if (!connection)
{
logger.error("The specified Patient-ID was not found.");
Expand Down
13 changes: 5 additions & 8 deletions tests/unit-tests/librelink/librelink.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import "jest";
import {
createFormattedMeasurements,
getGlucoseMeasurements,
getLibreLinkUpConnection,
login,
} from "../../../src";
import {createFormattedMeasurements, getGlucoseMeasurements, getLibreLinkUpConnection, login,} from "../../../src";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";

const mock = new MockAdapter(axios);
import {default as loginSuccessResponse} from "../../data/login.json";
import {default as loginFailedResponse} from "../../data/login-failed.json";
import {default as connectionsResponse} from "../../data/connections.json";
Expand All @@ -17,6 +10,9 @@ import {default as graphResponse} from "../../data/graph.json";
import {AuthTicket} from "../../../src/interfaces/librelink/common";
import {GraphData} from "../../../src/interfaces/librelink/graph-response";
import {Entry} from "../../../src/nightscout/interface";
import readConfig from "../../../src/config";

const mock = new MockAdapter(axios);

mock.onPost("https://api-eu.libreview.io/llu/auth/login").reply(200, loginSuccessResponse);
mock.onGet("https://api-eu.libreview.io/llu/connections").reply(200, connectionsResponse);
Expand Down Expand Up @@ -64,6 +60,7 @@ describe("LibreLink Up", () =>
it("Get available connections - Second available patient-id", async () =>
{
process.env.LINK_UP_CONNECTION = "77179667-ba4b-11eb-ad1f-0242ac110004";
const config = readConfig();
const connectionId: string | null = await getLibreLinkUpConnection();
expect(connectionId).toBe("77179667-ba4b-11eb-ad1f-0242ac110004");
});
Expand Down

0 comments on commit beb0566

Please sign in to comment.