diff --git a/index.mjs b/index.mjs index 717d835..4be3e79 100644 --- a/index.mjs +++ b/index.mjs @@ -1,6 +1,7 @@ import gc from 'garmin-connect'; import Trimlog from './lib/client.mjs'; -import { generateTrimlogInput, today } from './lib/tcx.mjs'; +import fs from "fs/promises"; +import { today } from './lib/tcx.mjs'; async function main() { const garmin = new gc.GarminConnect({ @@ -12,14 +13,14 @@ async function main() { password: process.env.TRIMLOG_PASSWORD, }) - await trimlog.refreshToken(); + await trimlog.submit({}); await garmin.login(); + console.log("CONNECTED!"); + const blackList = {}; setInterval(async() => { - const looseActivities = await trimlog.getLooseActivities(); - const recentWorkouts = await trimlog.getWorkouts(); const activities = await garmin.getActivities(); const todayDate = today(); @@ -40,25 +41,16 @@ async function main() { blackList[id] = true; continue; } - if (looseActivities.find(l => l.tags.find(t => t.key === "gc:ActivityID" && t.value === id))) { - console.error("Skipping", act.activityId, "(loose activity exists)"); - blackList[id] = true; - continue; - } - if (recentWorkouts.find(l => l.tags.find(t => t.key === "gc:ActivityID" && t.value === id))) { - console.error("Skipping", act.activityId, "(workout exists)"); - blackList[id] = true; - continue; - } console.log("Going ahead with", act.activityId, act.activityType.typeKey) await garmin.downloadOriginalActivityData(act, "/tmp/", "tcx") - const input = await generateTrimlogInput(`/tmp/${act.activityId}.tcx`, act.activityName, act.activityType?.typeKey) - - input.tags.push({key: "gc:ActivityID", value: id}) - - await trimlog.postWorkout(input); + const input = (await fs.readFile(`/tmp/${act.activityId}.tcx`)).toString("base64") + + await trimlog.submit({addUpload: { + name: `${act.activityId}.tcx`, + base64: input, + }}); blackList[id] = true; } diff --git a/lib/client.mjs b/lib/client.mjs index 92d8a72..04c1b81 100644 --- a/lib/client.mjs +++ b/lib/client.mjs @@ -1,56 +1,168 @@ +import WebSocket from "ws"; + export default class Trimlog { constructor({username, password}) { - this.token = null; - this.tokenExpiry = new Date(0); this.username = username; this.password = password; + this.socket = null; + this.inbox = []; } - async getLooseActivities() { - return this.fetch("GET", "activities"); + send(key, data) { + this.socket.send(JSON.stringify({ + [key]: data, + register: { + clientId: this.username, + clientSecret: this.password, + }, + })); } - async getWorkouts() { - return this.fetch("GET", `workouts?from=${new Date(Date.now() - 86400000 * 3).toISOString().slice(0, 10)}`); - } + waitFor(key) { + return new Promise((resolve, reject) => { + const ws = this.socket; + setInterval(() => { + if (ws !== this.socket) { + reject("disconnected"); + } - async postWorkout(data) { - return this.fetch("POST", "workouts", data); + for (const [i, data] of this.inbox.entries()) { + if (!!data[key]) { + this.inbox.splice(i, 1); + return data[key]; + } + } + }, 100) + }) } - async fetch(method, path, body) { - if (new Date() > this.tokenExpiry) { - await this.refreshToken(); - } + connect() { + return new Promise((resolve, reject) => { + if (this.socket != null) { + this.socket.close(); + } - const res = await fetch("https://i.stifred.dev/api/"+path, { - method: method, - headers: { - "content-type": body ? "application/json" : void(0), - "authorization": `Bearer ${this.token}` - }, - body: body ? JSON.stringify(body) : void(0), - }) + let answered = false; + const ws = new WebSocket("wss://i.stifred.dev/"); + + ws.on("open", () => { + ws.send(JSON.stringify({register: { + clientId: this.username, + clientSecret: this.password, + }})); - if (!res.ok) { - throw new Error(res.statusText); - } + const interval = setInterval(() => { + if (this.socket !== ws) { + clearInterval(interval); + } - return (await res.json()).data; - } + ws.send(JSON.stringify({ping: { + reference: "ping:" + new Date().toISOString(), + register: { + clientId: this.username, + clientSecret: this.password, + }, + }})) + }, 60000) - async refreshToken() { - const res = await fetch("https://stifred.auth.eu-west-1.amazoncognito.com/oauth2/token", { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - "authorization": `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}` - }, - body: "grant_type=client_credentials&scope=indigo/api", + this.socket = ws; + }) + + ws.on("message", (data) => { + data = JSON.parse(data); + if (data.signal) { + return + } + + console.log(JSON.stringify(data, 0, 4)); + + if (!answered) { + if (data.connection?.active) { + answered = true; + return resolve(); + } else if (data.error != null) { + return reject(data.error.message); + } + } + + this.inbox.push(data); + }) + + ws.on("error", (err) => { + if (!answered) { + answered = true; + return reject(err); + } + }) + + ws.on("close", () => { + if (this.socket === ws) { + this.socket = null; + + console.log("Socket closed") + this.connect().then(() => { + console.log("Socket reconnected") + }) + } + }) }) - const data = await res.json(); + } + + submit(message) { + return new Promise((resolve, reject) => { + const reference = Date.now().toString(36) + Math.random().toString(36).replace(".", ""); + + let answered = false; + let sent = false; + const ws = new WebSocket("wss://i.stifred.dev/"); + + ws.on("open", () => { + ws.send(JSON.stringify({register: { + clientId: this.username, + clientSecret: this.password, + }})); + }) + + ws.on("message", (data) => { + data = JSON.parse(data); + console.log(JSON.stringify(data, 0, 4)); - this.token = data.access_token; - this.tokenExpiry = new Date(Date.now() + (data.expires_in * 1000 * 0.95)); + if (data.signal?.reference === reference) { + if (!answered) { + answered = true; + resolve(); + ws.close(); + } + } + + if (!answered && !sent) { + if (data.connection?.active) { + ws.send(JSON.stringify({ + ...message, + ping: { reference }, + register: { + clientId: this.username, + clientSecret: this.password, + } + })); + + sent = true; + } + } + + this.inbox.push(data); + }) + + ws.on("error", (err) => { + if (!answered) { + answered = true; + return reject(err); + } + }) + + ws.on("close", () => { + console.log("Socket closed") + }) + }) } } diff --git a/package-lock.json b/package-lock.json index c2b4281..a7528fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "garmin-connect": "^1.6.1", + "ws": "^8.18.0", "xml2js": "^0.6.2" } }, @@ -303,6 +304,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -535,6 +556,12 @@ "object-inspect": "^1.9.0" } }, + "ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "requires": {} + }, "xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 37de0ba..6098321 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "garmin-connect": "^1.6.1", + "ws": "^8.18.0", "xml2js": "^0.6.2" } } diff --git a/stufftxt.mjs b/stufftxt.mjs new file mode 100644 index 0000000..f7ff027 --- /dev/null +++ b/stufftxt.mjs @@ -0,0 +1,77 @@ +import Trimlog from './lib/client.mjs'; + +async function readStdin() { + return new Promise((resolve) => { + const allLines = []; + let lingeringLine = ""; + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', function(chunk) { + const lines = chunk.split("\n"); + + lines[0] = lingeringLine + lines[0]; + lingeringLine = lines.pop(); + + allLines.push(...lines); + }); + + process.stdin.on('end', function() { + allLines.push(lingeringLine); + resolve(allLines); + }); + }) +} + +function parseLine(line) { + const split = line.split(" "); + const split2 = split[0].split(/[:,.]/g); + let seconds = parseInt(split2[0], 10) * 60; + if (split2.length > 1) { + seconds += parseInt(split2[1], 10); + } + + return ({ + seconds, + calories: parseInt(split[1]), + }); +} + +async function main() { + const [id] = process.argv.slice(2); + if (!id) { + return; + } + + const stuff = (await readStdin()) + .filter(l => l) + .map(parseLine) + .map(l => ({...l, roundId: id})) + + console.log(stuff); + + const trimlog = new Trimlog({ + username: process.env.TRIMLOG_USERNAME, + password: process.env.TRIMLOG_PASSWORD, + }) + + await trimlog.connect(); + console.log("CONNECTED!") + + trimlog.send("addRoundMeasurementBatch", { + measurements: stuff, + overwrite: true, + }); + + process.on('SIGINT', function() { + console.log("INTERRUPTED"); + process.exit(0); + }); + + process.on('SIGTERM', function() { + console.log("TERMINATED"); + process.exit(0); + }); +} + +main();