5 Commits
Author SHA1 Message Date
gisle 80ce539241 use new stuff
continuous-integration/drone Build is passing
2024-08-22 15:43:55 +02:00
gisle 239db9758d fix more 'tude. 2024-03-16 12:11:03 +01:00
gisle d15fa26e79 fix the 'tude
continuous-integration/drone Build is passing
2024-02-09 19:54:35 +01:00
gisle 4d1dd75c9d biking is not walking.
continuous-integration/drone/push Build is passing
2024-01-05 19:37:04 +01:00
gisle 1bd6e0c905 do not submit non-supported activities.
continuous-integration/drone/push Build is passing
2024-01-05 16:20:11 +01:00
6 changed files with 296 additions and 114 deletions
+19 -20
View File
@@ -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();
@@ -29,29 +30,27 @@ async function main() {
if (blackList[id]) {
continue;
}
if (!["walking", "cycling"].includes(act.activityType?.typeKey)) {
console.error("Skipping", act.activityId, `(${act.activityType?.typeKey} activity not supported)`);
blackList[id] = true;
continue;
}
if (!act.startTimeLocal.startsWith(todayDate)) {
console.error("Skipping", act.activityId, "(not today)");
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)
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;
}
+157 -45
View File
@@ -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");
}
async getWorkouts() {
return this.fetch("GET", `workouts?from=${new Date(Date.now() - 86400000 * 3).toISOString().slice(0, 10)}`);
}
async postWorkout(data) {
return this.fetch("POST", "workouts", data);
}
async fetch(method, path, body) {
if (new Date() > this.tokenExpiry) {
await this.refreshToken();
}
const res = await fetch("https://i.stifred.dev/api/"+path, {
method: method,
headers: {
"content-type": body ? "application/json" : void(0),
"authorization": `Bearer ${this.token}`
send(key, data) {
this.socket.send(JSON.stringify({
[key]: data,
register: {
clientId: this.username,
clientSecret: this.password,
},
body: body ? JSON.stringify(body) : void(0),
})
if (!res.ok) {
throw new Error(res.statusText);
}
return (await res.json()).data;
}));
}
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",
})
const data = await res.json();
waitFor(key) {
return new Promise((resolve, reject) => {
const ws = this.socket;
setInterval(() => {
if (ws !== this.socket) {
reject("disconnected");
}
this.token = data.access_token;
this.tokenExpiry = new Date(Date.now() + (data.expires_in * 1000 * 0.95));
for (const [i, data] of this.inbox.entries()) {
if (!!data[key]) {
this.inbox.splice(i, 1);
return data[key];
}
}
}, 100)
})
}
}
connect() {
return new Promise((resolve, reject) => {
if (this.socket != null) {
this.socket.close();
}
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,
}}));
const interval = setInterval(() => {
if (this.socket !== ws) {
clearInterval(interval);
}
ws.send(JSON.stringify({ping: {
reference: "ping:" + new Date().toISOString(),
register: {
clientId: this.username,
clientSecret: this.password,
},
}}))
}, 60000)
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")
})
}
})
})
}
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));
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")
})
})
}
}
+15 -49
View File
@@ -1,7 +1,7 @@
import * as fs from "fs/promises";
import { parseString } from "xml2js";
export function generateTrimlogInput(filename, name = "Walk") {
export function generateTrimlogInput(filename, name = "Walk", type = "walk") {
return new Promise(async(resolve, reject) => {
parseString(await fs.readFile(filename, "utf-8"), (err, res) => {
if (err != null) {
@@ -26,9 +26,9 @@ export function generateTrimlogInput(filename, name = "Walk") {
time: tp.Time[0],
longitude: parseFloat(tp.Position?.[0]?.LongitudeDegrees[0]),
latitude: parseFloat(tp.Position?.[0]?.LatitudeDegrees[0]),
altitude: parseFloat(tp.AltitudeMeters[0]),
distance: parseFloat(tp.DistanceMeters[0]),
heartrate: parseFloat(tp.HeartRateBpm?.[0].Value?.[0]),
altitude: parseFloat(tp.AltitudeMeters?.[0]),
distance: parseFloat(tp.DistanceMeters?.[0]),
pulse: parseFloat(tp.HeartRateBpm?.[0].Value?.[0]),
};
if (minAltitude == null || point.altitude < minAltitude) {
@@ -42,17 +42,25 @@ export function generateTrimlogInput(filename, name = "Walk") {
}
}
const list = Object.keys(map).sort().map(k => map[k]);
const firstLon = list.find(e => !!e.longitude)?.longitude;
const firstLat = list.find(e => !!e.latitude)?.latitude;
const firstAlt = list.find(e => !!e.altitude)?.altitude;
const out = {
date: dateStr,
contents: [{rawActivity: {
kind: "Walking",
kind: type === "cycling" ? "Biking" : "Walking",
sets: [],
effortScale: 1,
weight: 0,
measurements: Object.keys(map).sort().map(k => map[k]).map(p => ({
measurements: list.map(p => ({
seconds: Math.round((new Date(p.time) - date) / 1000),
meters: p.distance,
pulse: p.heartrate,
pulse: p.pulse,
longitude: p.longitude || firstLon,
latitude: p.latitude || firstLat,
altitude: p.altitude || firstAlt,
}))
}}],
description: name,
@@ -75,45 +83,3 @@ export function today() {
}
function pad(n) {return n > 9 ? n.toString() : `0${n}`}
/*
{
"date": "2023-12-31",
"contents": [
{
"rawActivity": {
"kind": "Walking",
"sets": [],
"measurements": [
{
"seconds": 1234,
"calories": 4321,
"meters": 432553
},
{
"seconds": 2468,
"calories": 42342,
"meters": 3424
},
{
"seconds": 3702,
"calories": 23423,
"meters": 2342
},
{
"seconds": 4936,
"calories": 23423,
"meters": 2342
}
],
"effortScale": 1,
"weight": 0
}
}
],
"description": "asdfasdfasdf",
"tags": []
}
*/
+27
View File
@@ -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",
+1
View File
@@ -1,6 +1,7 @@
{
"dependencies": {
"garmin-connect": "^1.6.1",
"ws": "^8.18.0",
"xml2js": "^0.6.2"
}
}
+77
View File
@@ -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();