iobroker.fitbit-fitness
Version:
retrieves data from FITBIT
691 lines (589 loc) • 21.1 kB
JavaScript
"use strict";
/*
* Created with @iobroker/create-adapter v2.0.2
*/
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require("@iobroker/adapter-core");
//const { errorMonitor } = require("events");
const axios = require("axios").default;
const mSchedule = require("node-schedule"); // https://github.com/node-schedule/node-schedule
const axiosTimeout = 8000;
const clientID = "2387KZ";
const clientSecret = "bf343e0474cca869afb218975585b2e2";
const BASE_URL = "https://api.fitbit.com/1/user/";
const BASE2_URL = "https://api.fitbit.com/1.2/user/";
const HEART_RATE_ZONE_RANGES = ["customHeartRateZones", "heartRateZones"];
class FitBit extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: "fitbit-fitness",
});
this.on("ready", this.onReady.bind(this));
this.on("stateChange", this.onStateChange.bind(this));
this.on("unload", this.onUnload.bind(this));
this.updateInterval = null;
this.fitbit = {};
this.fitbit.sleepRecordsStoredate = null;
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
// Initialize your adapter here
// The adapters config (in the instance object everything under the attribute "native") is accessible via
// this.config:
this.setState("info.connection", false, true);
this.login().
then(() => {
if (this.fitbit.status === 200) {
this.setState("info.connection", true, true);
//this.getTokenExpireDate(this.config.token);
this.initSleepSchedule();
this.getFitbitRecords(); // get data one time
this.updateInterval = setInterval(() => {
this.getFitbitRecords();
}, this.config.refresh * 1000 * 60); // in seconds
} else {
this.setState("info.connection", false, true);
this.log.warn(`FITBit login failed ${this.fitbit.status}`);
}
})
.catch((error) => {
this.log.error(`Adapter Connection: ${error} `);
});
this.subscribeStates("body.weight"); // fitbit-fitness.0.body.weight
this.subscribeStates("body.fat"); // fitbit-fitness.0.body.weight
//this.subscribeStates("*"); // fitbit-fitness.0.body.weight
}
async getFitbitRecords() {
try {
//this.log.debug(`Getting data for user ${this.fitbit.user.fullName}`);
if (await this.checkToken()) {
this.log.debug(`Tokens checked`);
}
if (this.config.activityrecords) {
await this.getActivityRecords();
await this.getHeartRateTimeSeries();
}
if (this.config.bodyrecords) {
await this.getBodyRecords();
}
if (this.config.foodrecords) {
await this.getFoodRecords();
}
if (this.config.sleeprecords && !this.config.sleeprecordsschedule) {
this.getSleepRecords();
}
if (this.config.devicerecords) {
this.getDeviceRecords();
}
}
catch (err) {
this.log.info(`Data retrieval ${err}`);
}
}
async login() {
try {
const url = "https://api.fitbit.com/1/user/-/profile.json";
if (this.config.owntoken && this.config.token != "") {
this.log.debug(`Using own token: ${this.config.token}`);
}
const accessToken = await this.getStateAsync("tokens.access");
const refreshToken = await this.getStateAsync("tokens.refresh");
if (accessToken && refreshToken && accessToken.val && refreshToken.val) {
this.fitbit.tokens = {
access_token: accessToken.val,
refresh_token: refreshToken.val
};
// this.log.debug(`Getting refresh Token: ${this.fitbit.tokens.refresh_token}`);
} else {
throw new Error("no tokens available. Recreate token in config");
}
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${this.fitbit.tokens.access_token}` },
timeout: axiosTimeout
});
this.fitbit.status = response.status;
if (this.fitbit.status === 200) {
this.setState("info.connection", true, true);
this.setUserStates(response.data);
}
}
catch (err) {
throw new Error(err);
}
}
setUserStates(data) {
this.fitbit.user = data.user; // Use instance object for data
this.log.info(`User logged in ${this.fitbit.user.fullName} id:${this.fitbit.user.encodedId}`);
this.setState("user.fullName", this.fitbit.user.fullName, true);
this.setState("user.userid", this.fitbit.user.encodedId, true);
}
initSleepSchedule() {
if (this.config.sleeprecordsschedule && this.config.sleeprecords) {
const rndMinutes = Math.floor(Math.random() * 59); // Randomize the start of the schedule
const rndHours = 20 + Math.floor(Math.random() * 2);
this.log.info(`Schedule for sleep activated. ${rndMinutes} ${rndHours} * * *`);
this.schedule = mSchedule.scheduleJob(`${rndMinutes} ${rndHours} * * *`, () => {
if (this.config.sleeprecords) {
this.getSleepRecords();
}
});
}
}
async writeHistorytoState(id, historyInstance = "history.0", statearr) {
// const statearr = [];
// statearr.push({ ts: tsNow, val: 99, q: 0 });
this.sendTo(historyInstance, "storeState", {
id: id,
state: statearr
}, result => {
console.log(`data inserted ${JSON.stringify(result)}`);
}
);
}
async setWeight(actWeight) {
const url = `${BASE_URL}-/body/log/weight.json`;
const token = this.fitbit.tokens.access_token;
const datetime = this.getDateTime();
const payload = `weight=${actWeight}&date=${datetime.dateString}&time=${datetime.time}`;
this.log.info(`Payload: ${payload}`);
try {
const response = await axios({
url: url,
method: "post",
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout,
data: payload
});
this.log.info(`Status: ${response.status}`);
// if (response.status === 200) {
// }
}
catch (err) {
this.log.warn(`setWeight: ${err}`);
}
}
async getHeartRateTimeSeries() {
const url = `${BASE_URL}-/activities/heart/date/today/1d.json`;
const token = this.fitbit.tokens.access_token;
try {
const response = await axios({
url: url,
method: "get",
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
if (response.status === 200) {
if (!this.setHeartRateTimeSeries(response.data)) {
this.log.debug(`Activity Records: No heart rate time series avaliable`);
}
}
}
catch (err) {
this.log.error("error in heartrate : " + err);
return (`Error in Heartrate ${err}`);
}
}
async getActivityRecords() {
const url = `${BASE_URL}-/activities/date/${this.getDateTime().dateString}.json`;
const token = this.fitbit.tokens.access_token;
//this.log.info(`url: ${url}`);
try {
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
// this.log.info(`getActivityRecords Status: ${response.status}`);
if (response.status === 200) {
if (!this.setActivityStates(response.data)) {
this.log.debug(`Activity Records: No activity records avaliable`);
}
}
}
catch (err) {
this.log.warn(`getActivityRecords: ${err}`);
}
}
setActivityStates(data) {
if (data.summary) {
this.fitbit.activities = data; // First record in the array
this.setState("activity.Steps", this.fitbit.activities.summary.steps ? this.fitbit.activities.summary.steps : 0, true);
this.setState("activity.Floors", this.fitbit.activities.summary.floors ? this.fitbit.activities.summary.floors : 0, true);
this.setState("activity.ActiveMinutes", this.fitbit.activities.summary.veryActiveMinutes ? this.fitbit.activities.summary.veryActiveMinutes : 0, true);
this.setState("activity.RestingHeartRate", this.fitbit.activities.summary.restingHeartRate ? this.fitbit.activities.summary.restingHeartRate : 0, true);
this.setState("activity.Calories", this.fitbit.activities.summary.caloriesOut ? this.fitbit.activities.summary.caloriesOut : 0, true);
this.setState("activity.ActivitiesCount", this.fitbit.activities.activities.length ? this.fitbit.activities.activities.length : 0, true);
return true;
} else {
return false;
//throw new Error("FITBit: No Activity records available");
}
}
async setHeartRateTimeSeries(data) {
data["activities-heart"].map(entry => {
Object.keys(entry.value).filter(e => HEART_RATE_ZONE_RANGES.includes(e)).forEach(zones => {
entry.value[zones].map(zone => {
const zoneName = zone.name.replace(this.FORBIDDEN_CHARS, "_");
Object.keys(zone).filter(z => z !== "name").map(entryValue => {
const entryValueName = entryValue.replace(this.FORBIDDEN_CHARS, "_");
this.setObjectNotExists(`activity.heartratezones.${zoneName}.${entryValueName}`, {type: "state", common: {name: `${entryValue} - ${zoneName}`, type: "number", read: true, write: false}, native: {}});
this.setState(`activity.heartratezones.${zoneName}.${entryValueName}`,{val: zone[entryValue] ? zone[entryValue] : 0, ack: true});
});
this.setObjectNotExists(`activity.heartratezones.${zoneName}.isCustom`, {type: "state", common: {name: `custom heart rate zone`, type: "boolean", read: true, write: false}, native: {}});
this.setState(`activity.heartratezones.${zoneName}.isCustom`,{val: zones.includes("custom"), ack: true});
});
});
});
}
async getDeviceRecords() {
const url = `${BASE_URL}-/devices.json`;
const token = this.fitbit.tokens.access_token;
try {
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
if (response.status === 200) {
this.setDeviceStates(response.data);
}
}
catch (err) {
this.log.warn(`getDeviceRecords: ${err}`);
}
}
async getBodyRecords() {
const url = `${BASE_URL}-/body/log/weight/date/${this.getDateTime().dateString}.json`;
const token = this.fitbit.tokens.access_token;
try {
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
if (response.status === 200) {
if (!this.setBodyStates(response.data)) {
this.log.debug(`Body Records: No weight records avaliable`);
}
}
}
catch (err) {
this.log.warn(`getBodyRecords: ${err}`);
}
}
setDeviceStates(data) {
if (data) {
this.setObjectNotExists("devices", {
type: "channel",
common: {
name: "FITBIT Devices",
},
native: {},
});
data.forEach(device => {
// this.log.info(`Device: ${device.deviceVersion} Battery: ${device.batteryLevel} `);
this.setObjectNotExists(`devices.${device.deviceVersion}`, {
type: "channel",
common: {
name: `${device.deviceVersion}`,
},
native: {},
});
const idbat = `devices.${device.deviceVersion}.battery`;
this.setObjectNotExists(idbat, {
type: "state",
common: {
name: "Battery",
role: "value.battery",
type: "string",
write: false,
read: true,
},
native: {},
});
this.setState(idbat, device.battery, true);
const idbatlev = `devices.${device.deviceVersion}.batteryLevel`;
this.setObjectNotExists(idbatlev, {
type: "state",
common: {
name: "Battery Level",
role: "level",
type: "number",
write: false,
read: true,
},
native: {},
});
this.setState(idbatlev, device.batteryLevel, true);
const idtype = `devices.${device.deviceVersion}.type`;
this.setObjectNotExists(idtype, {
type: "state",
common: {
name: "Device type",
role: "state",
type: "string",
write: false,
read: true,
},
native: {},
});
this.setState(idtype, device.type, true);
const idstatus = `devices.${device.deviceVersion}.batteryAlarm`;
this.setObjectNotExists(idstatus, {
type: "state",
common: {
name: "Device type",
role: "indicator.lowbat",
type: "boolean",
write: false,
read: true,
},
native: {},
});
this.setState(idstatus, device.battery.toLowerCase() === "empty", true);
});
return true;
}
else {
return false;
}
}
setBodyStates(data) {
if (data.weight.length > 0) {
this.fitbit.body = data.weight.slice(-1)[0]; // last record entry from a day
this.log.info(`Body records: Weight:${this.fitbit.body.weight} Fat:${this.fitbit.body.fat} BMI:${this.fitbit.body.bmi}`);
if (this.fitbit.body.weight)
this.setState("body.weight", this.fitbit.body.weight, true);
if (this.fitbit.body.fat)
this.setState("body.fat", this.fitbit.body.fat, true);
if (this.fitbit.body.bmi)
this.setState("body.bmi", this.fitbit.body.bmi, true);
return true;
}
else {
return false;
}
}
async getFoodRecords() {
//const url = "https://api.fitbit.com/1/user/-/foods/log/date/2022-02-01.json";
const url = `${BASE_URL}-/foods/log/date/${this.getDateTime().dateString}.json`;
const token = this.fitbit.tokens.access_token;
try {
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
if (response.status === 200) {
if (!this.setFoodStates(response.data)) {
this.log.debug(`Food Records: No food records avaliable`);
}
}
}
catch (err) {
this.log.warn(`getFoodRecords: ${err}`);
}
}
setFoodStates(data) {
if (data.summary) {
this.fitbit.food = data.summary; // First record in the array
this.log.info(`Food records: Cal:${this.fitbit.food.calories} Water:${this.fitbit.food.water} FAT:${this.fitbit.food.fat} Protein:${this.fitbit.food.protein}`);
this.setState("food.Water", this.fitbit.food.water, true);
this.setState("food.Calories", this.fitbit.food.calories, true);
this.setState("food.Carbs", this.fitbit.food.carbs, true);
this.setState("food.Sodium", this.fitbit.food.sodium, true);
this.setState("food.Fiber", this.fitbit.food.fiber, true);
this.setState("food.Fat", this.fitbit.food.fat, true);
this.setState("food.Protein", this.fitbit.food.protein, true);
return true;
} else {
return false;
//throw new Error("FITBit: No Food records available");
}
}
async getSleepRecords() {
const url = `${BASE2_URL}-/sleep/date/${this.getDateTime().dateString}.json`;
const token = this.fitbit.tokens.access_token;
//this.log.info(`url: ${url}`);
try {
const response = await axios.get(url,
{
headers: { "Authorization": `Bearer ${token}` },
timeout: axiosTimeout
});
if (response.status === 200) {
if (!this.setSleepStates(response.data)) {
this.log.debug(`Sleep Records: No sleep records avaliable`);
}
}
}
catch (err) {
this.log.warn(`getSleepRecords:${err}`);
}
}
setSleepStates(data) {
if (data.sleep.length > 0) {
this.fitbit.sleep = data.summary.stages; // First record in the array
//this.fitbit.sleep = data.summary.stages; // First record in the array
this.log.info(`Sleep records: Deep:${this.fitbit.sleep.deep} light:${this.fitbit.sleep.light} rem:${this.fitbit.sleep.rem} wake:${this.fitbit.sleep.wake}`);
this.setState("sleep.Deep", this.fitbit.sleep.deep, true);
this.setState("sleep.Light", this.fitbit.sleep.light, true);
this.setState("sleep.Rem", this.fitbit.sleep.rem, true);
this.setState("sleep.Wake", this.fitbit.sleep.wake, true);
return true;
} else {
return false;
//throw new Error("FITBit: No Sleep Data found");
}
}
async getTokenInfo() {
const token = this.fitbit.tokens.access_token;
try {
const url = "https://api.fitbit.com/1.1/oauth2/introspect";
const payload = `token=${token}`;
const response = await axios({
url: url,
method: "post",
headers: {
"authorization": `Bearer ${token}`,
//'accept': 'application/json',
//'Content-Type': 'application/x-www-form-urlencoded'
},
data: payload
});
this.fitbit.tokens = response.data;
this.log.info(`token expires: ${this.fitbit.tokens.exp}`);
//await this.setStateAsync("tokens.expire", this.fitbit.tokens.exp, true);
await this.setStateAsync("tokens.clientid", this.fitbit.tokens.client_id, true);
await this.setStateAsync("tokens.userid", this.fitbit.tokens.user_id, true);
return true;
}
catch (err) {
throw new Error(`${err}`);
}
}
async renewToken() {
try {
const url = "https://api.fitbit.com/oauth2/token";
const refreshToken = this.fitbit.tokens.refresh_token;
const response = await axios({
url: url,
method: "post",
headers: { "Authorization": `Basic ${Buffer.from(clientID + ":" + clientSecret).toString("base64")}` },
data: "grant_type=refresh_token&refresh_token=" + refreshToken,
timeout: axiosTimeout
});
this.fitbit.tokens = response.data;
if (response.status === 200) {
//this.log.info(`renew Token: ${this.fitbit.tokens.access_token} refresh: ${this.fitbit.tokens.refresh_token}`);
const time = new Date();
time.setSeconds(time.getSeconds() + this.fitbit.tokens.expires_in);
await this.setStateAsync("tokens.access", this.fitbit.tokens.access_token, true);
await this.setStateAsync("tokens.refresh", this.fitbit.tokens.refresh_token, true);
await this.setStateAsync("tokens.expire", time.toISOString(), true);
this.log.info(`Token renewed: ${time.toISOString()}`);
return true;
} else {
return false;
}
}
catch (err) {
this.log.error(`Renew Token`);
}
}
async checkToken() {
const stateExpire = await this.getStateAsync("tokens.expire");
if (!stateExpire || !stateExpire.val)
throw new Error("No valid tokens. Please authenticate in configuration");
const expireTime = new Date(stateExpire.val.toString()).getTime();
const now = new Date().getTime();
//this.log.info(`Expire Date time:${expireTime} DateNow ${now} left ${expireTime - now}`);
if (expireTime - now < 3600000) { // < 1 hour refresh the token time.toISOString()
//if (1 === 1) {
if (await this.renewToken()) {
return true;
} else return false;
} else {
//this.fitbit.tokens.token = await this.getStateAsync("tokens.access");
return true;
}
}
getDateTime(ts = new Date(), addDays = 0) {
const datetime = {};
const date = new Date(ts);
date.setDate(date.getDate() + addDays);
const dd = date.getDate();
const mm = date.getMonth() + 1;
const year = date.getFullYear();
const hh = date.getHours();
const mi = date.getMinutes();
const ss = date.getSeconds();
datetime.dateString = `${year}-${mm.toString(10).padStart(2, "0")}-${dd.toString(10).padStart(2, "0")}`;
datetime.date = date;
datetime.time = `${hh.toString(10).padStart(2, "0")}:${mi.toString(10).padStart(2, "0")}:${ss.toString(10).padStart(2, "0")}`;
datetime.timeShort = `${hh.toString(10).padStart(2, "0")}:${mi.toString(10).padStart(2, "0")}`;
datetime.ts = date.getTime();
return datetime;
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
* @param {() => void} callback
*/
onUnload(callback) {
try {
// Here you must clear all timeouts or intervals that may still be active
// clearTimeout(timeout1);
// clearInterval(interval1);
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.schedule) {
this.schedule.cancel();
}
callback();
} catch (e) {
callback();
}
}
/**
* Is called if a subscribed state changes
* @param {string} id
* @param {ioBroker.State | null | undefined} state
*/
async onStateChange(id, state) {
if (state) {
if (state && state.ack === false) {
if (id.indexOf("body.weight") !== -1) {
this.log.info(`weight changed ${id} changed: ${state.val} (ack = ${state.ack})`);
this.setWeight(state.val);
this.setState("body.weight",state.val,true);
}
// if (id.indexOf("body.fat") !== -1) {
// this.log.info(`fat changed ${id} changed: ${state.val} (ack = ${state.ack})`);
// }
}
} else {
// The state was deleted
this.log.info(`state ${id} deleted`);
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
module.exports = (options) => new FitBit(options);
} else {
// otherwise start the instance directly
new FitBit();
}