ble layer mostly done
379
ant/ant.js
@@ -1,379 +0,0 @@
|
||||
import { message } from './message.js';
|
||||
import { xf } from '../xf.js';
|
||||
import { first, empty, conj, xor, exists, delay } from '../functions.js';
|
||||
import { USB } from './usb.js';
|
||||
import { Channel } from './channel.js';
|
||||
|
||||
const ChannelTypes = {
|
||||
slave: {
|
||||
bidirectional: 0x00,
|
||||
sharedBidirectional: 0x20,
|
||||
receiveOnly: 0x40,
|
||||
},
|
||||
master: {
|
||||
bidirectional: 0x10,
|
||||
sharedBidirectional: 0x30,
|
||||
}
|
||||
};
|
||||
|
||||
const keys = {
|
||||
antPlus: [0xB9, 0xA5, 0x21, 0xFB, 0xBD, 0x72, 0xC3, 0x45],
|
||||
public: [0xE8, 0xE4, 0x21, 0x3B, 0x55, 0x7A, 0x67, 0xC1],
|
||||
};
|
||||
|
||||
const hrmFilter = {
|
||||
deviceType: 120,
|
||||
period: (32280 / 4),
|
||||
frequency: 57,
|
||||
key: keys.antPlus,
|
||||
};
|
||||
|
||||
const fecFilter = {
|
||||
deviceType: 17,
|
||||
period: (32768 / 4),
|
||||
frequency: 57,
|
||||
key: keys.antPlus,
|
||||
};
|
||||
|
||||
class FecChannel extends Channel {
|
||||
postInit(args) {}
|
||||
defaultNumber() { return 2; }
|
||||
defaultType() { return 0; }
|
||||
defaultDeviceType() { return 17; }
|
||||
defaultPeriod() { return (32768 / 4); } // 8192
|
||||
defaultFrequency() { return 57; }
|
||||
defaultKey() { return keys.antPlus; }
|
||||
onBroadcast(data) {
|
||||
const page = message.FECPage(data);
|
||||
if(('power' in page) && !isNaN(page.power)) {
|
||||
xf.dispatch('ant:fec:power', page.power);
|
||||
xf.dispatch('device:pwr', page.power);
|
||||
};
|
||||
if(('cadence' in page) && !isNaN(page.cadence)) {
|
||||
xf.dispatch('ant:fec:cadence', page.cadence);
|
||||
xf.dispatch('device:cad', page.cadence);
|
||||
};
|
||||
if(('speed' in page) && !isNaN(page.speed)) {
|
||||
xf.dispatch('ant:fec:speed', page.speed);
|
||||
xf.dispatch('device:spd', page.speed);
|
||||
};
|
||||
}
|
||||
connect() {
|
||||
const self = this;
|
||||
console.log(self.toMessageConfig());
|
||||
self.open();
|
||||
xf.dispatch('ant:fec:connected');
|
||||
}
|
||||
disconnect() {
|
||||
const self = this;
|
||||
self.close();
|
||||
xf.dispatch('ant:fec:disconnected');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class HrmChannel extends Channel {
|
||||
postInit(args) {}
|
||||
defaultNumber() { return 1; }
|
||||
defaultType() { return 0; }
|
||||
defaultDeviceType() { return 120; }
|
||||
defaultPeriod() { return (32280 / 4); }
|
||||
defaultFrequency() { return 57; }
|
||||
defaultKey() { return keys.antPlus; }
|
||||
onBroadcast(data) {
|
||||
const page = message.HRPage(data);
|
||||
if(!isNaN(page.hr)) {
|
||||
xf.dispatch('ant:hr', page.hr);
|
||||
xf.dispatch('device:hr', page.hr);
|
||||
}
|
||||
if('model' in page) {
|
||||
console.log(`model: ${page.model}`);
|
||||
}
|
||||
if('level' in page) {
|
||||
console.log(`level: ${page.level}`);
|
||||
}
|
||||
}
|
||||
async connect() {
|
||||
const self = this;
|
||||
console.log(self.toMessageConfig());
|
||||
await self.open();
|
||||
xf.dispatch('ant:hrm:connected');
|
||||
}
|
||||
disconnect() {
|
||||
const self = this;
|
||||
self.close();
|
||||
xf.dispatch('ant:hrm:disconnected');
|
||||
}
|
||||
isConnected() {
|
||||
const self = this;
|
||||
return self.isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDeviceId(id) {
|
||||
if(id.deviceNumber === undefined || isNaN(id.deviceNumber)) return false;
|
||||
if(id.deviceType === undefined || isNaN(id.deviceType)) return false;
|
||||
if(id.transType === undefined || isNaN(id.transType)) return false;
|
||||
return true;
|
||||
}
|
||||
function includesDevice(devices, id) {
|
||||
return devices.filter(d => d.deviceNumber === id.deviceNumber).length > 0;
|
||||
}
|
||||
|
||||
class SearchChannel extends Channel {
|
||||
postInit(args) {
|
||||
this.devices = [];
|
||||
}
|
||||
defaultNumber() { return 0; }
|
||||
defaultType() { return 0; }
|
||||
defaultDeviceType() { return 0; }
|
||||
defaultTimeoutLow() { return 255; }
|
||||
defaultTimeout() { return 0; }
|
||||
async open() {
|
||||
const self = this;
|
||||
let config = self.toMessageConfig();
|
||||
console.log(self.toMessageConfig());
|
||||
|
||||
await self.write(message.UnassignChannel(config).buffer);
|
||||
|
||||
await self.write(message.SetNetworkKey(config).buffer);
|
||||
await self.write(message.AssignChannelExt(conj(config, {extended: 0x01})).buffer);
|
||||
await self.write(message.ChannelId(config).buffer);
|
||||
await self.write(message.EnableExtRxMessages(conj(config, {enable: 1})).buffer);
|
||||
await self.write(message.LowPrioritySearchTimeout(config).buffer);
|
||||
await self.write(message.SearchTimeout(config).buffer);
|
||||
await self.write(message.ChannelFrequency(config).buffer);
|
||||
await self.write(message.ChannelPeriod(config).buffer);
|
||||
await self.write(message.OpenChannel(config).buffer);
|
||||
self.isOpen = true;
|
||||
console.log(`channel:open ${self.number}`);
|
||||
}
|
||||
onBroadcast(data) {
|
||||
const self = this;
|
||||
const { deviceNumber, deviceType, transType } = message.readExtendedData(data);
|
||||
const device = { deviceNumber, deviceType, transType };
|
||||
if(isValidDeviceId(device)) {
|
||||
if(!includesDevice(self.devices, device)) {
|
||||
self.devices.push(device);
|
||||
console.log(`Search found: ${deviceNumber} ${deviceType} ${transType}`);
|
||||
xf.dispatch(`ant:search:device-found`, device);
|
||||
}
|
||||
}
|
||||
}
|
||||
async start(filters) {
|
||||
const self = this;
|
||||
|
||||
self.deviceType = filters.deviceType || self.defaultDeviceType;
|
||||
self.period = filters.period || self.defaultPeriod;
|
||||
self.frequency = filters.frequency || self.defaultFrequency;
|
||||
self.key = filters.key || self.defaultKey;
|
||||
|
||||
self.devices = [];
|
||||
await self.open();
|
||||
xf.dispatch('ant:search:started');
|
||||
let status = await self.requestStatus();
|
||||
console.log(status);
|
||||
}
|
||||
async stop() {
|
||||
const self = this;
|
||||
let config = self.toMessageConfig();
|
||||
await self.write(message.EnableExtRxMessages(conj(config, {enable: 0})).buffer);
|
||||
self.close();
|
||||
xf.dispatch('ant:search:stopped');
|
||||
}
|
||||
isStarted() {
|
||||
const self = this;
|
||||
return self.isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
class AntDevice {
|
||||
constructor(args) {
|
||||
this.name = args.name || this.defaultName();
|
||||
this.filters = args.filters || this.defaultFilters();
|
||||
this.deviceId = args.deviceId || this.defaultDeviceId();
|
||||
this.ant = args.ant;
|
||||
this.connected = false;
|
||||
this.channelClass = args.channelClass || this.defaultChannelClass();
|
||||
this.init();
|
||||
this.postInit();
|
||||
}
|
||||
defaultName() { return 'antDevice'; }
|
||||
defaultDeviceId() {
|
||||
return { deviceNumber: 0, deviceType: 0, transType: 0 };
|
||||
}
|
||||
defaultFilters() {
|
||||
return {
|
||||
deviceType: 0,
|
||||
period: (32280 / 4),
|
||||
frequency: 57,
|
||||
key: keys.antPlus,
|
||||
};
|
||||
}
|
||||
defaultChannelClass() { return Channel; }
|
||||
async init() {
|
||||
const self = this;
|
||||
self.channel = new self.channelClass({write: ant.write.bind(ant)}); // -> this.ant.write
|
||||
ant.addChannel(self.channel.number, self.channel);
|
||||
}
|
||||
postInit() { return; }
|
||||
async request() {
|
||||
const self = this;
|
||||
return await ant.requestDevice({filters: self.filters});
|
||||
}
|
||||
async connect() {
|
||||
const self = this;
|
||||
xf.dispatch(`${self.name}:connecting`);
|
||||
|
||||
try {
|
||||
self.deviceId = await self.request();
|
||||
self.channel.setChannelId(self.deviceId);
|
||||
await self.channel.connect();
|
||||
self.connected = true;
|
||||
xf.dispatch(`${self.name}:connected`);
|
||||
} catch(err) {
|
||||
xf.dispatch(`${self.name}:disconnected`);
|
||||
console.log(`Ant Device request was canceled or failed.`);
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
disconnect() {
|
||||
const self = this;
|
||||
self.channel.disconnect();
|
||||
self.connected = false;
|
||||
xf.dispatch(`${self.name}:disconnected`);
|
||||
}
|
||||
}
|
||||
|
||||
class AntHrm extends AntDevice {
|
||||
async postInit() {
|
||||
const self = this;
|
||||
}
|
||||
defaultName() { return 'antHrm'; }
|
||||
defaultChannelClass() { return HrmChannel; }
|
||||
defaultDeviceId() {
|
||||
return {deviceNumber: 0, deviceType: 120, transType: 0};
|
||||
}
|
||||
defaultFilters() {
|
||||
return {
|
||||
deviceType: 120,
|
||||
period: (32280 / 4),
|
||||
frequency: 57,
|
||||
key: keys.antPlus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AntFec extends AntDevice {
|
||||
async postInit() {
|
||||
const self = this;
|
||||
}
|
||||
defaultName() { return 'antFec'; }
|
||||
defaultChannelClass() { return FecChannel; }
|
||||
defaultDeviceId() {
|
||||
return {deviceNumber: 0, deviceType: 17, transType: 0};
|
||||
}
|
||||
defaultFilters() {
|
||||
return {
|
||||
deviceType: 17,
|
||||
period: (32768 / 4),
|
||||
frequency: 57,
|
||||
key: keys.antPlus,
|
||||
};
|
||||
}
|
||||
async setPowerTarget(power) {
|
||||
const self = this;
|
||||
const buffer = message.powerTarget(power, self.channel.number).buffer;
|
||||
self.channel.write(buffer);
|
||||
}
|
||||
async setResistanceTarget(level) {
|
||||
const self = this;
|
||||
const buffer = message.resistanceTarget(level, self.channel.number).buffer;
|
||||
self.channel.write(buffer);
|
||||
}
|
||||
async setSlopeTarget(args) {
|
||||
const self = this;
|
||||
const buffer = message.slopeTarget(args.grade, self.channel.number).buffer;
|
||||
self.channel.write(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
class Ant {
|
||||
constructor(args) {
|
||||
this.driver = {};
|
||||
this.channels = {};
|
||||
this.ready = false;
|
||||
this.init();
|
||||
}
|
||||
async init() {
|
||||
const self = this;
|
||||
self.search = new SearchChannel({write: self.write.bind(self)});
|
||||
self.addChannel(0, self.search);
|
||||
|
||||
xf.sub(`db:antDeviceId`, deviceId => {
|
||||
self.deviceId = deviceId;
|
||||
});
|
||||
|
||||
xf.sub(`ui:ant:device:pair`, e => {
|
||||
self.onPair(self.deviceId);
|
||||
self.search.stop();
|
||||
});
|
||||
xf.sub(`ui:ant:device:cancel`, e => {
|
||||
self.search.stop();
|
||||
self.onCancel();
|
||||
});
|
||||
|
||||
// USB
|
||||
xf.sub('ant:disconnected', _ => {
|
||||
self.search.disconnect();
|
||||
this.ready = false;
|
||||
});
|
||||
|
||||
xf.sub('usb:ready', _ => {
|
||||
console.log('usb:ready');
|
||||
self.ready = true;
|
||||
|
||||
// restore state on page reload
|
||||
Object.values(self.channels).forEach(channel => channel.disconnect());
|
||||
});
|
||||
|
||||
self.driver = new USB({onData: self.onData.bind(self)});
|
||||
await self.driver.init();
|
||||
}
|
||||
isAvailable() {
|
||||
const self = this;
|
||||
return self.driver.isAvailable();
|
||||
}
|
||||
async requestDevice(args) {
|
||||
const self = this;
|
||||
const filters = args.filters;
|
||||
await self.search.start(filters);
|
||||
xf.dispatch(`ant:device:request`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
self.onPair = resolve;
|
||||
self.onCancel = reject;
|
||||
});
|
||||
}
|
||||
addChannel(number, channel) {
|
||||
const self = this;
|
||||
self.channels[number] = channel;
|
||||
}
|
||||
onData(data) {
|
||||
const self = this;
|
||||
if(message.isValid(data)) {
|
||||
let number = message.readChannel(data);
|
||||
if(self.channels[number] !== undefined) {
|
||||
self.channels[number].onData(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
write(buffer) {
|
||||
const self = this;
|
||||
self.driver.write(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
const ant = new Ant();
|
||||
|
||||
export { ant, AntHrm, AntFec };
|
||||
214
ant/channel.js
@@ -1,214 +0,0 @@
|
||||
import { message } from './message.js';
|
||||
import { xf } from '../xf.js';
|
||||
import { first, empty, conj, xor, exists, delay } from '../functions.js';
|
||||
|
||||
const keys = {
|
||||
antPlus: [0xB9, 0xA5, 0x21, 0xFB, 0xBD, 0x72, 0xC3, 0x45],
|
||||
public: [0xE8, 0xE4, 0x21, 0x3B, 0x55, 0x7A, 0x67, 0xC1],
|
||||
};
|
||||
|
||||
class Channel {
|
||||
constructor(args) {
|
||||
this._number = args.number || this.defaultNumber();
|
||||
this._type = args.type || this.defaultType();
|
||||
this._deviceType = args.deviceType || this.defaultDeviceType();
|
||||
this._deviceNumber = args.deviceNumber || this.defaultDeviceNumber();
|
||||
this._transType = args.transType || this.defaultTransType();
|
||||
this._period = args.period || this.defaultPeriod();
|
||||
this._frequency = args.frequency || this.defaultFrequency();
|
||||
this._timeout = args.timeout || this.defaultTimeout();
|
||||
this._timeoutLow = args.timeoutLow || this.defaultTimeoutLow();
|
||||
this._key = args.key || this.defaultKey();
|
||||
this.write = args.write || ((x) => x);
|
||||
this._status = '';
|
||||
this.isOpen = false;
|
||||
this.resq = {};
|
||||
this.postInit(args);
|
||||
}
|
||||
postInit() { return null; }
|
||||
get number() { return this._number; }
|
||||
set number(x) { return this._number = x; }
|
||||
get type() { return this._type; }
|
||||
set type(x) { return this._type = x; }
|
||||
get deviceType() { return this._deviceType; }
|
||||
set deviceType(x) { return this._deviceType = x; }
|
||||
get deviceNumber() { return this._deviceNumber; }
|
||||
set deviceNumber(x) { return this._deviceNumber = x; }
|
||||
get transType() { return this._transType; }
|
||||
set transType(x) { return this._transType = x; }
|
||||
get period() { return this._period; }
|
||||
set period(x) { return this._period = x; }
|
||||
get frequency() { return this._frequency; }
|
||||
set frequency(x) { return this._frequency = x; }
|
||||
get timeout() { return this._timeout; }
|
||||
set timeout(x) { return this._timeout = x; }
|
||||
get timeoutLow() { return this._timeoutLow; }
|
||||
set timeoutLow(x) { return this._timeoutLow = x; }
|
||||
get key() { return this._key; }
|
||||
set key(x) { return this._key = x; }
|
||||
get status() { return this._status; }
|
||||
set status(x) { return this._status = x; }
|
||||
setChannelId(id) {
|
||||
const self = this;
|
||||
self.deviceType = id.deviceType || self.defaultDeviceType();
|
||||
self.deviceNumber = id.deviceNumber || self.defaultDeviceNumber();
|
||||
self.transType = id.transType || self.defaultTransType();
|
||||
}
|
||||
defaultNumber() { return 0; }
|
||||
defaultType() { return 0; }
|
||||
defaultDeviceType() { return 0; }
|
||||
defaultDeviceNumber() { return 0; }
|
||||
defaultTransType() { return 0; }
|
||||
defaultPeriod() { return 8192; }
|
||||
defaultFrequency() { return 66; }
|
||||
defaultTimeout() { return 12; }
|
||||
defaultTimeoutLow() { return 2; }
|
||||
defaultKey() { return keys.public; }
|
||||
async writeWithResponse(msg, id = 0, timeout = 3000) {
|
||||
const self = this;
|
||||
|
||||
let timeoutId;
|
||||
let timeoutReject = {};
|
||||
let timeoutResolve = {};
|
||||
let responseReject = {};
|
||||
let responseResolve = {};
|
||||
|
||||
let timeoutPromise = new Promise((resolve, reject) => {
|
||||
timeoutReject = resolve;
|
||||
timeoutResolve = reject;
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutReject(`Channel ${self.number} response timeout for ${message.idToString(id)}`);
|
||||
responseReject();
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
let responsePromise = new Promise((resolve, reject) => {
|
||||
responseReject = resolve;
|
||||
responseResolve = reject;
|
||||
self.resq[id] = (x) => { responseResolve(x); timeoutResolve(); };
|
||||
});
|
||||
await self.write(msg);
|
||||
const res = await Promise.race([responsePromise, timeoutPromise])
|
||||
.catch((err) => { console.log(err); });
|
||||
return res;
|
||||
}
|
||||
async request(id) {
|
||||
const self = this;
|
||||
return await self.writeWithResponse(message.Request({channelNumber: self.number, request: id}).buffer, id);
|
||||
}
|
||||
async requestStatus() {
|
||||
const self = this;
|
||||
return await self.request(message.ids.channelStatus);
|
||||
}
|
||||
async requestId() {
|
||||
const self = this;
|
||||
return await self.request(message.ids.channelId);
|
||||
}
|
||||
async open() {
|
||||
const self = this;
|
||||
let config = self.toMessageConfig();
|
||||
|
||||
await self.write(message.UnassignChannel(config).buffer);
|
||||
|
||||
await self.write(message.SetNetworkKey(config).buffer);
|
||||
await self.write(message.AssignChannel(config).buffer);
|
||||
await self.write(message.ChannelId(config).buffer);
|
||||
await self.write(message.ChannelFrequency(config).buffer);
|
||||
await self.write(message.ChannelPeriod(config).buffer);
|
||||
await self.write(message.OpenChannel(config).buffer);
|
||||
self.isOpen = true;
|
||||
console.log(`channel:open ${self.number}`);
|
||||
}
|
||||
async close() {
|
||||
const self = this;
|
||||
let config = self.toMessageConfig();
|
||||
await self.write(message.CloseChannel(config).buffer); // , message.ids.closeChannel
|
||||
self.isOpen = false;
|
||||
console.log(`channel:close ${self.number}`);
|
||||
return;
|
||||
}
|
||||
connect() { this.open(); }
|
||||
disconnect() { this.close(); }
|
||||
onData(data) {
|
||||
const self = this;
|
||||
if(self.isOpen) {
|
||||
if(message.isBroadcast(data)) { self.onBroadcast(data); }
|
||||
if(message.isAcknowledged(data)) { self.onBroadcast(data); }
|
||||
if(message.isBurst(data)) { self.onBroadcast(data); }
|
||||
if(message.isResponse(data)) { self.onResponse(data); }
|
||||
if(message.isRequestedResponse(data)) { self.onRequestedResponse(data); }
|
||||
if(message.isEvent(data)) { self.onEvent(data); }
|
||||
if(message.isSerialError(data)) { self.onSerialError(data); }
|
||||
if(message.isBroadcastExt(data)) { self.onBroadcast(data); }
|
||||
if(message.isBurstAdv(data)) { self.onBroadcast(data); }
|
||||
}
|
||||
}
|
||||
onBroadcast(data) {
|
||||
const self = this;
|
||||
return data;
|
||||
}
|
||||
onResponse(data) {
|
||||
const self = this;
|
||||
const { channel, id, toId, code } = message.readResponse(data);
|
||||
const idStr = message.idToString(id);
|
||||
const toIdStr = message.idToString(toId);
|
||||
const codeStr = message.eventCodeToString(code);
|
||||
console.log(`Channel ${channel} ${toIdStr}: ${codeStr} ${data}`);
|
||||
|
||||
if(toId === message.ids.closeChannel) {}
|
||||
}
|
||||
onRequestedResponse(data) {
|
||||
const self = this;
|
||||
let res;
|
||||
if(message.isChannelId(data)) {
|
||||
res = message.readChannelId(data);
|
||||
self.resq[message.ids.channelId](res);
|
||||
}
|
||||
if(message.isChannelStatus(data)) {
|
||||
res = message.readChannelStatus(data);
|
||||
self.resq[message.ids.channelStatus](res);
|
||||
}
|
||||
if(message.isANTVersion(data)) {
|
||||
res = message.readANTVersion(data);
|
||||
self.resq[message.ids.ANTVersion](res);
|
||||
}
|
||||
if(message.isCapabilities(data)){
|
||||
res = message.readCapabilities(data);
|
||||
self.resq[message.ids.capabilities](res);
|
||||
}
|
||||
if(message.isSerialNumber(data)){
|
||||
res = message.readSerialNumber(data);
|
||||
self.resq[message.ids.serialNumber](res);
|
||||
}
|
||||
}
|
||||
onEvent(data) {
|
||||
const self = this;
|
||||
const { channel, code } = message.readEvent(data);
|
||||
|
||||
if(code === message.events.event_channel_closed) {
|
||||
self.resq[message.ids.closeChannel]();
|
||||
}
|
||||
console.log(`Channel ${channel} event: ${message.eventCodeToString(code)} ${data}`);
|
||||
}
|
||||
onSerialError(error) {
|
||||
const self = this;
|
||||
console.error(`Serial error: ${error}`);
|
||||
}
|
||||
toMessageConfig() {
|
||||
const self = this;
|
||||
return {
|
||||
channelNumber: self.number,
|
||||
channelType: self.type,
|
||||
deviceType: self.deviceType,
|
||||
deviceNumber: self.deviceNumber,
|
||||
transType: self.transType,
|
||||
channelPeriod: self.period,
|
||||
rfFrequency: self.frequency,
|
||||
timeout: self.timeout,
|
||||
timeoutLow: self.timeoutLow,
|
||||
key: self.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { Channel };
|
||||
640
ant/fit.js
@@ -1,640 +0,0 @@
|
||||
import { avgOfArray,
|
||||
maxOfArray,
|
||||
mps,
|
||||
sum,
|
||||
first,
|
||||
last,
|
||||
round,
|
||||
timeDiff } from '../functions.js';
|
||||
|
||||
const global_msg_numbers = {
|
||||
file_id: 0,
|
||||
session: 18,
|
||||
lap: 19,
|
||||
record: 20,
|
||||
event: 21,
|
||||
device_info: 23,
|
||||
activity: 34,
|
||||
};
|
||||
|
||||
const basetypes = {
|
||||
'enum': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'sint8': {base_type_number: 1, base_type_field: 0x01, endian_ability: 0, size: 1, invalid_value: 0x7F},
|
||||
'uint8': {base_type_number: 2, base_type_field: 0x02, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'sint16': {base_type_number: 3, base_type_field: 0x83, endian_ability: 0, size: 2, invalid_value: 0x7FFF},
|
||||
'uint16': {base_type_number: 4, base_type_field: 0x84, endian_ability: 0, size: 2, invalid_value: 0xFFFF},
|
||||
'sint32': {base_type_number: 5, base_type_field: 0x85, endian_ability: 0, size: 4, invalid_value: 0x7FFFFFFF},
|
||||
'uint32': {base_type_number: 6, base_type_field: 0x86, endian_ability: 0, size: 4, invalid_value: 0xFFFFFFFF},
|
||||
'string': {base_type_number: 7, base_type_field: 0x07, endian_ability: 0, size: 1, invalid_value: 0x00},
|
||||
'float32': {base_type_number: 8, base_type_field: 0x88, endian_ability: 0, size: 4, invalid_value: 0xFFFFFFFF},
|
||||
'float64': {base_type_number: 9, base_type_field: 0x89, endian_ability: 0, size: 8, invalid_value: 0xFFFFFFFFFFFFFFFF},
|
||||
'uint8z': {base_type_number: 10, base_type_field: 0x0A, endian_ability: 0, size: 1, invalid_value: 0x00},
|
||||
'uint16z': {base_type_number: 11, base_type_field: 0x8B, endian_ability: 0, size: 2, invalid_value: 0x0000},
|
||||
'uint32z': {base_type_number: 12, base_type_field: 0x8C, endian_ability: 0, size: 4, invalid_value: 0x00000000},
|
||||
'byte': {base_type_number: 13, base_type_field: 0x0D, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'sint64': {base_type_number: 14, base_type_field: 0x8E, endian_ability: 0, size: 8, invalid_value: 0x7FFFFFFFFFFFFFFF},
|
||||
'uint64': {base_type_number: 15, base_type_field: 0x8F, endian_ability: 0, size: 8, invalid_value: 0xFFFFFFFFFFFFFFFF},
|
||||
'uint64z': {base_type_number: 16, base_type_field: 0x90, endian_ability: 0, size: 8, invalid_value: 0x0000000000000000},
|
||||
|
||||
// SDK types
|
||||
'date_time': {base_type_number: 6, base_type_field: 0x86, endian_ability: 0, size: 4, invalid_value: 0xFFFFFFFF},
|
||||
|
||||
// enum types
|
||||
'file': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'type': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'manufacturer': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'event': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'event_type': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'sport': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
'sub_sport': {base_type_number: 0, base_type_field: 0x00, endian_ability: 0, size: 1, invalid_value: 0xFF},
|
||||
};
|
||||
|
||||
const enums = {
|
||||
file: {activity: 4, workout: 5},
|
||||
activity: {manual: 0, auto_multi_sport: 1},
|
||||
manufacturer: {dynastream: 15, wahoo: 32, elite: 86, tacx: 89, development: 255, zwift: 260},
|
||||
product: {},
|
||||
event: {timer: 0, session: 8, lap: 9, activity: 26, calibration: 36}, // 0(start), 8 (stop), 9(stop)
|
||||
event_type: {start: 0, stop: 1, stop_all: 4},
|
||||
event_group: {default: 0, one: 1}, // ? maybe, can't seem to find the values
|
||||
sport: {cycling: 2},
|
||||
sub_sport: {indoor_cycling: 6},
|
||||
};
|
||||
|
||||
const garmin_epoch = Date.parse('31 Dec 1989 00:00:00 GMT');
|
||||
|
||||
const toFitTimestamp = (timestamp) => Math.round((timestamp - garmin_epoch) / 1000);
|
||||
const toJsTimestamp = (fitTimestamp) => (fitTimestamp * 1000) + garmin_epoch;
|
||||
const now = _ => toFitTimestamp(Date.now());
|
||||
|
||||
const getBitField = (field, bit) => (field >> bit) & 1;
|
||||
const readUint16 = (blob, start) => blob[start]+(blob[start+1] << 8);
|
||||
const readUint32 = (blob, start) => blob[start]+ (blob[start+1] << 8) + (blob[start+2] << 16) + (blob[start+3] << 24);
|
||||
|
||||
function FieldDefinition(args) {
|
||||
let type = args.type; // type (from base type table)
|
||||
let number = args.number; // Defined in the Global FIT profile
|
||||
let size = basetypes[type].size;
|
||||
let base_type = basetypes[type].base_type_field;
|
||||
|
||||
let buffer = new ArrayBuffer(3);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, number, true);
|
||||
view.setUint8(1, size, true);
|
||||
view.setUint8(2, base_type, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
let field_definitions = {
|
||||
file_id: {
|
||||
type: FieldDefinition({number: 0, type: 'file'}),
|
||||
manufacturer: FieldDefinition({number: 1, type: 'uint16'}),
|
||||
product: FieldDefinition({number: 2, type: 'uint16'}),
|
||||
serial_number: FieldDefinition({number: 3, type: 'uint32z'}),
|
||||
time_created: FieldDefinition({number: 4, type: 'date_time'}),
|
||||
// number: FieldDefinition({number: 5, type: 'uint16'}), // only set for files not created
|
||||
product_name: FieldDefinition({number: 8, type: 'string'}),
|
||||
},
|
||||
|
||||
event: {
|
||||
timestamp: FieldDefinition({number: 253, type: 'date_time'}),
|
||||
event: FieldDefinition({number: 0, type: 'event'}),
|
||||
event_type: FieldDefinition({number: 1, type: 'event_type'}),
|
||||
event_group: FieldDefinition({number: 4, type: 'uint8'}),
|
||||
},
|
||||
|
||||
record: {
|
||||
timestamp: FieldDefinition({number: 253, type: 'date_time'}),
|
||||
heart_rate: FieldDefinition({number: 3, type: 'uint8'}),
|
||||
cadence: FieldDefinition({number: 4, type: 'uint8'}),
|
||||
distance: FieldDefinition({number: 5, type: 'uint32'}),
|
||||
speed: FieldDefinition({number: 6, type: 'uint16'}),
|
||||
power: FieldDefinition({number: 7, type: 'uint16'}),
|
||||
},
|
||||
|
||||
lap: {
|
||||
message_index: FieldDefinition({number: 254, type: 'uint16'}),
|
||||
timestamp: FieldDefinition({number: 253, type: 'date_time'}),
|
||||
event: FieldDefinition({number: 0, type: 'event'}),
|
||||
event_type: FieldDefinition({number: 1, type: 'event_type'}),
|
||||
start_time: FieldDefinition({number: 2, type: 'date_time'}),
|
||||
total_elapsed_time: FieldDefinition({number: 7, type: 'uint32'}),
|
||||
total_timer_time: FieldDefinition({number: 8, type: 'uint32'}),
|
||||
total_distance: FieldDefinition({number: 9, type: 'uint32'}),
|
||||
avg_speed: FieldDefinition({number: 13, type: 'uint16'}),
|
||||
max_speed: FieldDefinition({number: 14, type: 'uint16'}),
|
||||
avg_heart_rate: FieldDefinition({number: 15, type: 'uint8'}),
|
||||
max_heart_rate: FieldDefinition({number: 16, type: 'uint8'}),
|
||||
avg_cadence: FieldDefinition({number: 17, type: 'uint8'}),
|
||||
max_cadence: FieldDefinition({number: 18, type: 'uint8'}),
|
||||
avg_power: FieldDefinition({number: 19, type: 'uint16'}),
|
||||
max_power: FieldDefinition({number: 20, type: 'uint16'}),
|
||||
event_group: FieldDefinition({number: 26, type: 'uint8'}),
|
||||
},
|
||||
|
||||
session: {
|
||||
timestamp: FieldDefinition({number: 253, type: 'date_time'}),
|
||||
event: FieldDefinition({number: 0, type: 'event'}),
|
||||
event_type: FieldDefinition({number: 1, type: 'event_type'}),
|
||||
start_time: FieldDefinition({number: 2, type: 'date_time'}),
|
||||
sport: FieldDefinition({number: 5, type: 'sport'}),
|
||||
sub_sport: FieldDefinition({number: 6, type: 'sub_sport'}),
|
||||
total_elapsed_time: FieldDefinition({number: 7, type: 'uint32'}),
|
||||
total_timer_time: FieldDefinition({number: 8, type: 'uint32'}),
|
||||
total_distance: FieldDefinition({number: 9, type: 'uint32'}),
|
||||
avg_speed: FieldDefinition({number: 14, type: 'uint16'}),
|
||||
max_speed: FieldDefinition({number: 15, type: 'uint16'}),
|
||||
avg_heart_rate: FieldDefinition({number: 16, type: 'uint8'}),
|
||||
max_heart_rate: FieldDefinition({number: 17, type: 'uint8'}),
|
||||
avg_cadence: FieldDefinition({number: 18, type: 'uint8'}),
|
||||
max_cadence: FieldDefinition({number: 19, type: 'uint8'}),
|
||||
avg_power: FieldDefinition({number: 20, type: 'uint16'}),
|
||||
max_power: FieldDefinition({number: 21, type: 'uint16'}),
|
||||
first_lap_index: FieldDefinition({number: 25, type: 'uint16'}),
|
||||
num_laps: FieldDefinition({number: 26, type: 'uint16'}),
|
||||
event_group: FieldDefinition({number: 27, type: 'uint8'}),
|
||||
},
|
||||
|
||||
activity: {
|
||||
timestamp: FieldDefinition({number: 253, type: 'date_time'}),
|
||||
total_timer_time: FieldDefinition({number: 0, type: 'uint32'}),
|
||||
num_sessions: FieldDefinition({number: 1, type: 'uint16'}),
|
||||
type: FieldDefinition({number: 2, type: 'enum'}),
|
||||
event: FieldDefinition({number: 3, type: 'event'}),
|
||||
event_type: FieldDefinition({number: 4, type: 'event_type'}),
|
||||
local_timestamp: FieldDefinition({number: 5, type: 'date_time'}),
|
||||
event_group: FieldDefinition({number: 6, type: 'uint8'}),
|
||||
}
|
||||
};
|
||||
|
||||
function FitFileHeader(args) {
|
||||
let headerSize = 14; // size is 12(depricated) or 14
|
||||
let protocolVersion = 32; // 16 v1, 32 v2
|
||||
let profileVersion = 2140; // v21.40
|
||||
let dataSize = args.size - headerSize - 2; // without header and crc
|
||||
let dataTypeByte = [46, 70, 73, 84]; // ASCII values for ".FIT"
|
||||
let crc = 0x0000; // optional, crc of the header 0-11 bytes
|
||||
|
||||
let buffer = new ArrayBuffer(headerSize); // size is 12 or 14
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, headerSize, true);
|
||||
view.setUint8( 1, protocolVersion, true);
|
||||
view.setUint16(2, profileVersion, true);
|
||||
view.setInt32( 4, dataSize, true);
|
||||
view.setUint8( 8, dataTypeByte[0], true);
|
||||
view.setUint8( 9, dataTypeByte[1], true);
|
||||
view.setUint8(10, dataTypeByte[2], true);
|
||||
view.setUint8(11, dataTypeByte[3], true);
|
||||
|
||||
crc = calculateCRC(new Uint8Array(buffer), 0, 12);
|
||||
|
||||
view.setUint16(12, crc, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
function calculateCRC(blob, start, end) {
|
||||
const crcTable = [
|
||||
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
|
||||
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
|
||||
];
|
||||
|
||||
let crc = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
const byte = blob[i];
|
||||
let tmp = crcTable[crc & 0xF];
|
||||
crc = (crc >> 4) & 0x0FFF;
|
||||
crc = crc ^ tmp ^ crcTable[byte & 0xF];
|
||||
tmp = crcTable[crc & 0xF];
|
||||
crc = (crc >> 4) & 0x0FFF;
|
||||
crc = crc ^ tmp ^ crcTable[(byte >> 4) & 0xF];
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
function MsgHeader(args) {
|
||||
let header = 0b00000000;
|
||||
|
||||
let normalHeader = 0b00000000; // bit 7 = 1
|
||||
let timestampHeader = 0b10000000; // bit 7 = 0
|
||||
let definitionMsg = 0b01000000; // bit 6 = 1
|
||||
let dataMsg = 0b00000000; // bit 6 = 0
|
||||
let hasDevData = 0b00100000; // bit 5 = 0
|
||||
let noDevData = 0b00000000; // bit 5 = 0
|
||||
let localMsgType = args.localMsgType || 0b00000000; // bit 3-0 values (0...15)
|
||||
|
||||
header |= args.localMsgType;
|
||||
|
||||
if(args.headerType === 'normal') header |= normalHeader;
|
||||
if(args.headerType === 'timestamp') header |= timestampHeader;
|
||||
if(args.msgType === 'definition') header |= definitionMsg;
|
||||
if(args.msgType === 'data') header |= dataMsg;
|
||||
if(args.developerDataFlag) header |= hasDevData;
|
||||
if(!args.developerDataFlag) header |= noDevData;
|
||||
if(!args.developerDataFlag) header |= noDevData;
|
||||
|
||||
return header;
|
||||
}
|
||||
function DefinitionMsgHeader() {
|
||||
return MsgHeader({headerType: 'normal', msgType: 'definition', developerDataFlag: false});
|
||||
}
|
||||
function DataMsgHeader() {
|
||||
return MsgHeader({headerType: 'normal', msgType: 'data', developerDataFlag: false});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Definition Message factory
|
||||
function DefinitionMsg(args) {
|
||||
let header = DefinitionMsgHeader(); // 0b01000000 = 64
|
||||
|
||||
let architecture = 0; // 0 LittleEndian, 1 BigEndian
|
||||
let globalMsgNumber = args.globalMsgNumber || 0;
|
||||
let numberOfFields = args.fields.length;
|
||||
let fields = args.fields;
|
||||
|
||||
let i = 6;
|
||||
let fieldDefinitionLength = 3;
|
||||
let size = i + (numberOfFields * fieldDefinitionLength);
|
||||
|
||||
let buffer = new ArrayBuffer(size);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint8( 1, 0, true); // reserved
|
||||
view.setUint8( 2, architecture, true);
|
||||
view.setUint16(3, globalMsgNumber, true);
|
||||
view.setUint8( 5, numberOfFields, true);
|
||||
|
||||
for(let field=0; field < numberOfFields; field++) {
|
||||
for(let fdi=0; fdi < fieldDefinitionLength; fdi++) {
|
||||
view.setUint8(i, fields[field].view.getUint8(fdi), true);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Data Messages
|
||||
function FileIdMsg() {
|
||||
let header = DataMsgHeader();
|
||||
let type = 4;
|
||||
let manufacturer = 255; //15;
|
||||
let product = 0; //22;
|
||||
let serial_number = 1234;
|
||||
let time_created = now();
|
||||
|
||||
let buffer = new ArrayBuffer(14);
|
||||
// let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint8( 1, type, true);
|
||||
view.setUint16( 2, manufacturer, true);
|
||||
view.setUint16( 4, product, true);
|
||||
view.setUint32( 6, serial_number, true);
|
||||
view.setUint32(10, time_created, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
|
||||
function EventMsg(args = {}) {
|
||||
let header = DataMsgHeader();
|
||||
let timestamp = args.timestamp || now();
|
||||
let event = args.event || enums.event.timer;
|
||||
let event_type = args.event_type || enums.event_type.start;
|
||||
let event_group = args.event_group || enums.event_group.default;
|
||||
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint32(1, timestamp, true);
|
||||
view.setUint8( 5, event, true);
|
||||
view.setUint8( 6, event_type, true);
|
||||
view.setUint8( 7, event_group, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
function RecordMsg(args) {
|
||||
let header = DataMsgHeader();
|
||||
let timestamp = args.timestamp || now();
|
||||
let power = args.power || 0;
|
||||
let speed = args.speed || 0;
|
||||
let cadence = args.cadence || 0;
|
||||
let heart_rate = args.heart_rate || 0;
|
||||
let distance = args.distance || 0;
|
||||
|
||||
|
||||
let buffer = new ArrayBuffer(15);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint32( 1, timestamp, true);
|
||||
view.setUint16( 5, power, true);
|
||||
view.setUint16( 7, speed, true);
|
||||
view.setUint8( 9, cadence, true);
|
||||
view.setUint8( 10, heart_rate, true);
|
||||
view.setUint32(11, distance, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
function LapMsg(args = {}) {
|
||||
let header = DataMsgHeader();
|
||||
let timestamp = args.timestamp || now();
|
||||
let event = args.event || enums.event.lap;
|
||||
let event_type = args.event_type || enums.event_type.stop;
|
||||
let event_group = args.event_group || enums.event_group.default;
|
||||
let start_time = args.start_time || now();
|
||||
let total_elapsed_time = args.total_elapsed_time || 0;
|
||||
let avg_power = args.avg_power || 0;
|
||||
let max_power = args.max_power || 0;
|
||||
|
||||
let buffer = new ArrayBuffer(20);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint32( 1, timestamp, true);
|
||||
view.setUint8( 5, event, true);
|
||||
view.setUint8( 6, event_type, true);
|
||||
view.setUint8( 7, event_group, true);
|
||||
view.setUint32( 8, start_time, true);
|
||||
view.setUint32(12, total_elapsed_time, true);
|
||||
view.setUint16(16, avg_power, true);
|
||||
view.setUint16(18, max_power, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
function SessionMsg(args = {}) {
|
||||
let header = DataMsgHeader();
|
||||
let timestamp = args.timestamp || now();
|
||||
let event = args.event || enums.event.session;
|
||||
let event_type = args.event_type || enums.event_type.stop;
|
||||
let event_group = args.event_group || enums.event_type.default;
|
||||
let start_time = args.start_time || now();
|
||||
let total_timer_time = args.total_timer_time || 0;
|
||||
let total_elapsed_time = args.total_elapsed_time || 0;
|
||||
let first_lap_index = args.first_lap_index || 0;
|
||||
let num_laps = args.num_laps || 1;
|
||||
let avg_power = args.avg_power || 0;
|
||||
let max_power = args.max_power || 0;
|
||||
let sport = args.sport || enums.sport.cycling;
|
||||
let sub_sport = args.sub_sport || enums.sub_sport.indoor_cycling;
|
||||
|
||||
// let buffer = new ArrayBuffer(30);
|
||||
let buffer = new ArrayBuffer(26);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint32( 1, timestamp, true);
|
||||
view.setUint8( 5, event, true);
|
||||
view.setUint8( 6, event_type, true);
|
||||
view.setUint8( 7, event_group, true);
|
||||
view.setUint32( 8, start_time, true);
|
||||
view.setUint32(12, total_elapsed_time, true);
|
||||
view.setUint32(16, total_timer_time, true);
|
||||
view.setUint16(20, first_lap_index, true);
|
||||
view.setUint16(22, num_laps, true);
|
||||
view.setUint8( 24, sport, true);
|
||||
view.setUint8( 25, sub_sport, true);
|
||||
// view.setUint16(26, avg_power, true);
|
||||
// view.setUint16(28, max_power, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
function ActivityMsg(args = {}) {
|
||||
let header = DataMsgHeader();
|
||||
let timestamp = args.timestamp || now();
|
||||
let local_timestamp = args.local_timestamp || now();
|
||||
let event = args.event || enums.event.activity;
|
||||
let event_type = args.event_type || enums.event_type.stop;
|
||||
let event_group = args.event_group || enums.event_type.default;
|
||||
let type = args.type || enums.activity.manual;
|
||||
let total_timer_time = args.total_timer_time || 0;
|
||||
let num_sessions = args.num_sessions || 1;
|
||||
|
||||
let buffer = new ArrayBuffer(19);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, header, true);
|
||||
view.setUint32( 1, timestamp, true);
|
||||
view.setUint32( 5, local_timestamp, true);
|
||||
view.setUint8( 9, event, true);
|
||||
view.setUint8( 10, event_type, true);
|
||||
view.setUint8( 11, event_group, true);
|
||||
view.setUint8( 12, type, true);
|
||||
view.setUint32(13, total_timer_time, true);
|
||||
view.setUint16(17, num_sessions, true);
|
||||
|
||||
return {view: view, buffer: buffer};
|
||||
}
|
||||
|
||||
let definitionMsgs = {
|
||||
fileId: DefinitionMsg({globalMsgNumber: global_msg_numbers.file_id,
|
||||
fields: [field_definitions.file_id.type,
|
||||
field_definitions.file_id.manufacturer,
|
||||
field_definitions.file_id.product,
|
||||
field_definitions.file_id.serial_number,
|
||||
field_definitions.file_id.time_created,
|
||||
]}),
|
||||
|
||||
event: DefinitionMsg({globalMsgNumber: global_msg_numbers.event,
|
||||
fields: [field_definitions.event.timestamp,
|
||||
field_definitions.event.event,
|
||||
field_definitions.event.event_type,
|
||||
field_definitions.event.event_group,
|
||||
]}),
|
||||
|
||||
record: DefinitionMsg({globalMsgNumber: global_msg_numbers.record,
|
||||
fields: [field_definitions.record.timestamp,
|
||||
field_definitions.record.power,
|
||||
field_definitions.record.speed,
|
||||
field_definitions.record.cadence,
|
||||
field_definitions.record.heart_rate,
|
||||
field_definitions.record.distance,
|
||||
]}),
|
||||
|
||||
lap: DefinitionMsg({globalMsgNumber: global_msg_numbers.lap,
|
||||
fields: [field_definitions.lap.timestamp,
|
||||
field_definitions.lap.event,
|
||||
field_definitions.lap.event_type,
|
||||
field_definitions.lap.event_group,
|
||||
field_definitions.lap.start_time,
|
||||
field_definitions.lap.total_elapsed_time,
|
||||
field_definitions.lap.avg_power,
|
||||
field_definitions.lap.max_power,
|
||||
]}),
|
||||
|
||||
session: DefinitionMsg({globalMsgNumber: global_msg_numbers.session,
|
||||
fields: [field_definitions.session.timestamp,
|
||||
field_definitions.session.event,
|
||||
field_definitions.session.event_type,
|
||||
field_definitions.session.event_group,
|
||||
field_definitions.session.start_time,
|
||||
field_definitions.session.total_elapsed_time,
|
||||
field_definitions.session.total_timer_time,
|
||||
field_definitions.session.first_lap_index,
|
||||
field_definitions.session.num_laps,
|
||||
field_definitions.session.sport,
|
||||
field_definitions.session.sub_sport,
|
||||
// field_definitions.session.avg_power,
|
||||
// field_definitions.session.max_power,
|
||||
]}),
|
||||
|
||||
activity: DefinitionMsg({globalMsgNumber: global_msg_numbers.activity,
|
||||
fields: [field_definitions.activity.timestamp,
|
||||
field_definitions.activity.local_timestamp,
|
||||
field_definitions.activity.event,
|
||||
field_definitions.activity.event_type,
|
||||
field_definitions.activity.event_group,
|
||||
field_definitions.activity.type,
|
||||
field_definitions.activity.total_timer_time,
|
||||
field_definitions.activity.num_sessions,
|
||||
]}),
|
||||
};
|
||||
|
||||
function calculateFileByteLength(args) {
|
||||
const data = args.data.length || 1;
|
||||
const laps = args.laps ? args.laps.length : 1;
|
||||
const header = 14;
|
||||
const crc = 2;
|
||||
const record = RecordMsg(first(args.data)).view.byteLength;
|
||||
const fileId = FileIdMsg().view.byteLength;
|
||||
const event = EventMsg().view.byteLength;
|
||||
const lap = LapMsg().view.byteLength;
|
||||
const session = SessionMsg().view.byteLength;
|
||||
const activity = ActivityMsg().view.byteLength;
|
||||
|
||||
const fileIdDefinition = definitionMsgs.fileId.view.byteLength;
|
||||
const eventDefinition = definitionMsgs.event.view.byteLength;
|
||||
const recordDefinition = definitionMsgs.record.view.byteLength;
|
||||
const lapDefinition = definitionMsgs.lap.view.byteLength;
|
||||
const sessionDefinition = definitionMsgs.session.view.byteLength;
|
||||
const activityDefinition = definitionMsgs.activity.view.byteLength;
|
||||
|
||||
return sum([
|
||||
header,
|
||||
|
||||
fileIdDefinition, fileId,
|
||||
eventDefinition, event,
|
||||
recordDefinition, record * data,
|
||||
eventDefinition, event,
|
||||
lapDefinition, lap * laps,
|
||||
sessionDefinition, session,
|
||||
activityDefinition, activity,
|
||||
|
||||
crc
|
||||
]);
|
||||
}
|
||||
|
||||
function dataToRecords(args) {
|
||||
let header = args.header;
|
||||
let data = args.data;
|
||||
let laps = args.laps;
|
||||
|
||||
let avgPower = round(avgOfArray(data, 'power'));
|
||||
let maxPower = maxOfArray(data, 'power');
|
||||
let elapsed = timeDiff(first(data).timestamp, last(data).timestamp) * 1000;
|
||||
let timer = elapsed + 1000;
|
||||
|
||||
let timeStart = toFitTimestamp(first(data).timestamp);
|
||||
let timeEnd = toFitTimestamp(last(data).timestamp);
|
||||
let timeEndLocal = timeEnd;
|
||||
|
||||
let records = [];
|
||||
|
||||
records[0] = header;
|
||||
records[1] = definitionMsgs.fileId;
|
||||
records[2] = FileIdMsg();
|
||||
records[3] = definitionMsgs.event;
|
||||
records[4] = EventMsg({timestamp: timeStart,
|
||||
event_type: enums.event_type.start});
|
||||
|
||||
records[5] = definitionMsgs.record;
|
||||
|
||||
for(let d=0; d < data.length; d++) {
|
||||
records.push(RecordMsg({timestamp: toFitTimestamp(data[d].timestamp),
|
||||
power: data[d].power,
|
||||
speed: (data[d].speed / 3.6) * 1000,
|
||||
cadence: data[d].cadence,
|
||||
heart_rate: data[d].hr,
|
||||
distance: data[d].distance * 100,
|
||||
}));
|
||||
}
|
||||
|
||||
records.push(definitionMsgs.event);
|
||||
records.push(EventMsg({timestamp: timeEnd,
|
||||
event_type: enums.event_type.stop_all}));
|
||||
records.push(definitionMsgs.lap);
|
||||
|
||||
for(let l=0; l < laps.length; l++) {
|
||||
records.push(LapMsg({timestamp: toFitTimestamp(laps[l].timestamp),
|
||||
start_time: toFitTimestamp(laps[l].startTime),
|
||||
total_elapsed_time: laps[l].totalElapsedTime * 1000,
|
||||
avg_power: laps[l].avgPower,
|
||||
max_power: laps[l].maxPower,
|
||||
message_index: l}));
|
||||
}
|
||||
|
||||
records.push(definitionMsgs.session);
|
||||
records.push(SessionMsg({timestamp: timeEnd,
|
||||
start_time: timeStart,
|
||||
total_elapsed_time: elapsed,
|
||||
total_timer_time: timer,
|
||||
first_lap_index: 0,
|
||||
num_laps: laps.length,
|
||||
avg_power: avgPower,
|
||||
max_power: maxPower,
|
||||
message_index: 0}));
|
||||
|
||||
records.push(definitionMsgs.activity);
|
||||
records.push(ActivityMsg({timestamp: timeEnd,
|
||||
local_timestamp: timeEndLocal,
|
||||
total_timer_time: timer,
|
||||
num_sessions: 1}));
|
||||
return records;
|
||||
}
|
||||
|
||||
function Encode(args) {
|
||||
let data = args.data;
|
||||
let laps = args.laps;
|
||||
|
||||
let fileByteLength = calculateFileByteLength({data: data, laps: laps});
|
||||
let dataByteLength = (fileByteLength - 14) - 2;
|
||||
let header = FitFileHeader({size: fileByteLength});
|
||||
let records = dataToRecords({header: header, data: data, laps: laps});
|
||||
let buffer = new ArrayBuffer(fileByteLength);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
let i = 0;
|
||||
for(let r= 0; r < records.length; r++) {
|
||||
for(let v= 0; v < records[r].view.byteLength; v++) {
|
||||
view.setUint8(i, records[r].view.getUint8(v), true);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
let crc = calculateCRC(new Uint8Array(view.buffer.slice(14, fileByteLength - 2)), 0, dataByteLength);
|
||||
view.setUint16( i, crc, true);
|
||||
|
||||
let activity = view.buffer;
|
||||
// console.log(laps);
|
||||
// console.log(records);
|
||||
console.log(fileByteLength);
|
||||
|
||||
// downloadActivity(activity);
|
||||
return activity;
|
||||
}
|
||||
export { Encode }
|
||||
786
ant/message.js
@@ -1,786 +0,0 @@
|
||||
import { xor, nthBitToBool, arrayToString } from '../functions.js';
|
||||
|
||||
const ids = {
|
||||
// config
|
||||
setNetworkKey: 70, // 0x46
|
||||
unassignChannel: 65, // 0x41
|
||||
assignChannel: 66, // 0x42
|
||||
channelPeriod: 67, // 0x43
|
||||
channelFrequency: 69, // 0x45
|
||||
setChannelId: 81, // 0x51
|
||||
serialNumberSet: 101, // 0x65
|
||||
searchTimeout: 68, // 0x44
|
||||
searchLowTimeout: 99, // 0x63
|
||||
enableExtRx: 102, // 0x66
|
||||
|
||||
// control
|
||||
resetSystem: 74, // 0x4A
|
||||
openChannel: 75, // 0x4B
|
||||
closeChannel: 76, // 0x4C
|
||||
requestMessage: 77, // 0x4D
|
||||
sleepMessage: 197, // 0xC5
|
||||
|
||||
// notification
|
||||
startUp: 111, // 0x6F
|
||||
serialError: 174, // 0xAE
|
||||
|
||||
// data
|
||||
broascastData: 78, // 0x4E
|
||||
acknowledgedData: 79, // 0x4F
|
||||
broascastExtData: 93, // 0x5D
|
||||
burstData: 80, // 0x50
|
||||
burstAdvData: 114, // 0x72
|
||||
|
||||
// channel
|
||||
// channelEvent: 64, // 0x40
|
||||
channelEvent: 1, // 0x01
|
||||
channelResponse: 64, // 0x40
|
||||
|
||||
// requested response
|
||||
channelStatus: 82, // 0x52
|
||||
channelId: 81, // 0x51 response
|
||||
ANTVersion: 62, // 0x3E
|
||||
capabilities: 84, // 0x54
|
||||
serialNumber: 97 // 0x61
|
||||
};
|
||||
|
||||
const events = {
|
||||
response_no_error: 0,
|
||||
event_rx_search_timeout: 1,
|
||||
event_rx_fail: 2,
|
||||
event_tx: 3,
|
||||
event_transfer_rx_failed: 4,
|
||||
event_transfer_tx_completed: 5,
|
||||
event_transfer_tx_failed: 6,
|
||||
event_channel_closed: 7,
|
||||
event_rx_fail_go_to_search: 8,
|
||||
event_channel_collision: 9,
|
||||
event_transfer_tx_start: 10,
|
||||
event_transfer_next_data_block: 11,
|
||||
channel_in_wrong_state: 21,
|
||||
channel_not_opened: 22,
|
||||
channel_id_not_set: 24,
|
||||
close_all_channels: 25,
|
||||
transfer_in_progress: 31,
|
||||
transfer_sequence_number_error: 32,
|
||||
transfer_in_error: 33,
|
||||
message_size_exceeds_limit: 34,
|
||||
invalid_message: 40,
|
||||
invalid_network_number: 41,
|
||||
invalid_list_id: 48,
|
||||
invalid_scan_tx_channel: 49,
|
||||
invalid_parameter_provided: 51,
|
||||
event_serial_que_overflow: 52,
|
||||
event_que_overflow: 53,
|
||||
encrypt_negotiation_success: 56,
|
||||
encrypt_negotiation_fail: 57,
|
||||
nvm_full_error: 64,
|
||||
nvm_write_error: 65,
|
||||
usb_string_write_fail: 112,
|
||||
mesg_serial_error_id: 174
|
||||
};
|
||||
|
||||
function SetNetworkKey(args) {
|
||||
let buffer = new ArrayBuffer(13);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 9;
|
||||
const id = 70; // 0x46
|
||||
const key = args.key || keys.public;
|
||||
const networkNumber = args.networkNumber || 0;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, networkNumber, true);
|
||||
|
||||
|
||||
let j = 4;
|
||||
for(let i=0; i<9; i++) {
|
||||
view.setUint8(j, key[i], true);
|
||||
j++;
|
||||
}
|
||||
|
||||
view.setUint8(12, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function AssignChannel(args) {
|
||||
const sync = 164; // 0xA4
|
||||
const length = 3;
|
||||
const id = 66; // 0x42
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const channelType = args.channelType || 0;
|
||||
const networkNumber = 0;
|
||||
|
||||
let buffer = new ArrayBuffer(7);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, channelType, true);
|
||||
view.setUint8(5, networkNumber, true);
|
||||
view.setUint8(6, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function AssignChannelExt(args) {
|
||||
const sync = 164; // 0xA4
|
||||
const length = 4;
|
||||
const id = 66; // 0x42
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const channelType = args.channelType || 0;
|
||||
const networkNumber = 0;
|
||||
const extended = args.extended || 0x01;
|
||||
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, channelType, true);
|
||||
view.setUint8(5, networkNumber, true);
|
||||
view.setUint8(6, extended, true);
|
||||
view.setUint8(7, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function ChannelId(args) {
|
||||
let buffer = new ArrayBuffer(9);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 5;
|
||||
const id = 81; // 0x51
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const deviceNumber = args.deviceNumber || 0;
|
||||
const deviceType = args.deviceType || 0;
|
||||
const transmitionType = args.transType || 0;
|
||||
|
||||
view.setUint8( 0, sync, true);
|
||||
view.setUint8( 1, length, true);
|
||||
view.setUint8( 2, id, true);
|
||||
view.setUint8( 3, channelNumber, true);
|
||||
view.setUint16(4, deviceNumber, true);
|
||||
view.setUint8( 6, deviceType, true);
|
||||
view.setUint8( 7, transmitionType, true);
|
||||
view.setUint8( 8, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function ChannelFrequency(args) {
|
||||
let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 2;
|
||||
const id = 69; // 0x45
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const rfFrequency = args.rfFrequency || 66;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, rfFrequency, true);
|
||||
view.setUint8(5, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function ChannelPeriod(args) {
|
||||
let buffer = new ArrayBuffer(7);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 3;
|
||||
const id = 67; // 0x43
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const period = args.channelPeriod || 8192;
|
||||
|
||||
view.setUint8( 0, sync, true);
|
||||
view.setUint8( 1, length, true);
|
||||
view.setUint8( 2, id, true);
|
||||
view.setUint8( 3, channelNumber, true);
|
||||
view.setUint16(4, period, true);
|
||||
view.setUint8( 6, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function OpenChannel(args) {
|
||||
let buffer = new ArrayBuffer(5);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 1;
|
||||
const id = 75; // 0x4B
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function UnassignChannel(args) {
|
||||
let buffer = new ArrayBuffer(5);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 1;
|
||||
const id = 65; // 0x41
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function CloseChannel(args) {
|
||||
let buffer = new ArrayBuffer(5);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 1;
|
||||
const id = 76; //0x4C
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function ResetSystem(args) {
|
||||
let buffer = new ArrayBuffer(5);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 1;
|
||||
const id = 74; //0x4A
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, 0, true);
|
||||
view.setUint8(4, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function SearchTimeout(args) {
|
||||
let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 2;
|
||||
const id = 68; // 0x44
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const timeout = args.timeout; // 12 * 2.5 = 30s, 255 is infinite
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, timeout, true);
|
||||
view.setUint8(5, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function LowPrioritySearchTimeout(args) {
|
||||
let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 2;
|
||||
const id = 99; // 0x63
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const timeout = args.timeoutLow || 2; // 2 * 2.5 = 5s, 255 is infinite
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, timeout, true);
|
||||
view.setUint8(5, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function EnableExtRxMessages(args) {
|
||||
let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 2;
|
||||
const id = 102; // 0x66
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, 0, true);
|
||||
view.setUint8(4, args.enable, true);
|
||||
view.setUint8(5, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function Request(args) {
|
||||
let buffer = new ArrayBuffer(6);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 2;
|
||||
const id = 77; // 0x4D
|
||||
const channelNumber = args.channelNumber || 0;
|
||||
const request = args.request || 0;
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, channelNumber, true);
|
||||
view.setUint8(4, request, true);
|
||||
view.setUint8(5, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function readExtendedData(data) {
|
||||
const length = data[1];
|
||||
const id = data[2];
|
||||
const channelNumber = data[3];
|
||||
const flag = data[12];
|
||||
const deviceNumber = (data[14] << 8) + (data[13]);
|
||||
const deviceType = data[15];
|
||||
const transType = data[16];
|
||||
return { deviceNumber, deviceType, transType };
|
||||
}
|
||||
|
||||
function readChannelStatus(data) {
|
||||
const id = 82; // 0x52
|
||||
const channelNumber = data[3];
|
||||
const status = data[4] & 0b00000011; // just bits 0 and 1
|
||||
let res = 'unknown';
|
||||
if(status === 0) res = 'unassaigned';
|
||||
if(status === 1) res = 'assaigned';
|
||||
if(status === 2) res = 'searching';
|
||||
if(status === 3) res = 'tracking';
|
||||
return res;
|
||||
}
|
||||
|
||||
function readChannelId(data) {
|
||||
const id = 81; // 0x51
|
||||
const channelNumber = data[3];
|
||||
const deviceNumber = (data[5] << 8) + data[4];
|
||||
const deviceType = data[6];
|
||||
const transType = data[7];
|
||||
return { channelNumber, deviceNumber, deviceType, transType };
|
||||
}
|
||||
|
||||
function readANTVersion(data) {
|
||||
const id = 62; // 0x3E
|
||||
const version = arrayToString(data.slice(3));
|
||||
return { version };
|
||||
}
|
||||
|
||||
function readSerialNumber(data) {
|
||||
const id = 97; // 0x61
|
||||
const sn = data.slice(3);
|
||||
return { sn };
|
||||
}
|
||||
|
||||
function readCapabilities(data) {
|
||||
const id = 84; // 0x54
|
||||
const maxAntChannels = data[3];
|
||||
const maxNetworks = data[4];
|
||||
const standardOptions = data[5];
|
||||
const advancedOptions = data[6];
|
||||
const advancedOptions2 = data[7];
|
||||
const maxSensRcore = data[8];
|
||||
const advancedOptions3 = data[9];
|
||||
const advancedOptions4 = data[10];
|
||||
return { maxAntChannels,
|
||||
maxNetworks,
|
||||
standardOptions,
|
||||
advancedOptions,
|
||||
advancedOptions2,
|
||||
maxSensRcore,
|
||||
advancedOptions3,
|
||||
advancedOptions4};
|
||||
}
|
||||
|
||||
function Sleep(args) {
|
||||
let buffer = new ArrayBuffer(5);
|
||||
let view = new DataView(buffer);
|
||||
const sync = 164; // 0xA4
|
||||
const length = 1;
|
||||
const id = 197; // 0xC5
|
||||
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, id, true);
|
||||
view.setUint8(3, 0, true);
|
||||
view.setUint8(4, xor(view), true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
// FE-C
|
||||
function decodePower(powerMSB, powerLSB) {
|
||||
return ((powerMSB & 0b00001111) << 8) + (powerLSB);
|
||||
}
|
||||
function decoupleStatus(powerMSB) {
|
||||
return powerMSB >> 4;
|
||||
}
|
||||
function decodeStatus(bits) {
|
||||
return {
|
||||
powerCalibration: nthBitToBool(bits, 0),
|
||||
resistanceCalibration: nthBitToBool(bits, 1),
|
||||
userConfiguration: nthBitToBool(bits, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function dataPage25(msg) {
|
||||
// Specific Tr data, 0x19
|
||||
const updateEventCount = msg[5];
|
||||
const cadence = msg[6]; // rpm
|
||||
const powerLSB = msg[9]; // 8bit Power Lsb
|
||||
const powerMSB = msg[10]; // 4bit Power Msb + 4bit Status
|
||||
const flags = msg[11];
|
||||
|
||||
const power = decodePower(powerMSB, powerLSB);
|
||||
const status = decoupleStatus(powerMSB);
|
||||
|
||||
return { power, cadence, status, page: 25 };
|
||||
}
|
||||
|
||||
function dataPage16(msg) {
|
||||
// General FE data, 0x10
|
||||
const resolution = 0.001;
|
||||
const equipmentType = msg[5];
|
||||
let speed = (msg[9] << 8) + (msg[8]);
|
||||
const flags = msg[11];
|
||||
// const distance = msg.getUint8(7); // 255 rollover
|
||||
// const hr = msg.getUint8(10); // optional
|
||||
speed = (speed * resolution * 3.6);
|
||||
return { speed, page: 16 };
|
||||
}
|
||||
|
||||
function dataPage48(resistance) {
|
||||
// Data Page 48 (0x30) – Basic Resistance
|
||||
const dataPage = 48;
|
||||
const unit = 0.5;
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, dataPage, true);
|
||||
view.setUint8(7, resistance / 0.5, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function dataPage49(power) {
|
||||
// Data Page 49 (0x31) – Target Power
|
||||
const dataPage = 49;
|
||||
const unit = 0.25;
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, dataPage, true);
|
||||
view.setUint16(6, power / unit, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function compansateGradeOffset(slope) {
|
||||
// slope is coming as -> 1.8% * 100 = 180
|
||||
// 0 = -200%, 20000 = 0%, 40000 = 200%
|
||||
return 20000 + (slope);
|
||||
}
|
||||
|
||||
// compansateGradeOffset(0) === 20000
|
||||
// compansateGradeOffset(1) === 20100
|
||||
// compansateGradeOffset(4.5) === 20450
|
||||
// compansateGradeOffset(10) === 21000
|
||||
|
||||
function dataPage51(slope) {
|
||||
// Data Page 51 (0x33) – Track Resistance
|
||||
const dataPage = 51;
|
||||
const gradeUnit = 0.01;
|
||||
const crrUnit = 5*Math.pow(10,-5); // 5x10^-5
|
||||
const grade = compansateGradeOffset(slope);
|
||||
const crr = 0xFF; // default value
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, dataPage, true);
|
||||
view.setUint16(5, grade, true);
|
||||
view.setUint8( 7, crr, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function controlMessage(content, channel = 5) {
|
||||
const sync = 164;
|
||||
const length = 9;
|
||||
const type = 79; // Acknowledged 0x4F
|
||||
let buffer = new ArrayBuffer(13);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, type, true);
|
||||
view.setUint8(3, channel, true);
|
||||
|
||||
let j = 4;
|
||||
for(let i = 0; i < 8; i++) {
|
||||
view.setUint8(j, content.getUint8(i), true);
|
||||
j++;
|
||||
}
|
||||
|
||||
const crc = xor(view);
|
||||
view.setUint8(12, crc, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function powerTarget(power, channel = 5) {
|
||||
return controlMessage(dataPage49(power), channel);
|
||||
}
|
||||
function resistanceTarget(level, channel = 5) {
|
||||
return controlMessage(dataPage48(level), channel);
|
||||
}
|
||||
function slopeTarget(slope, channel = 5) {
|
||||
return controlMessage(dataPage51(slope), channel);
|
||||
}
|
||||
|
||||
function readSync(msg) {
|
||||
return msg[0];
|
||||
}
|
||||
function readLength(msg) {
|
||||
return msg[1];
|
||||
}
|
||||
function readId(msg) {
|
||||
return msg[2];
|
||||
}
|
||||
function readChannel(msg) {
|
||||
return msg[3];
|
||||
}
|
||||
|
||||
function isValidEventCode(code) {
|
||||
return Object.values(events).includes(code);
|
||||
}
|
||||
function eventCodeToString(code) {
|
||||
if(!isValidEventCode(code)) {
|
||||
return `invalid event code`;
|
||||
}
|
||||
const prop = Object.entries(events)
|
||||
.filter(e => e[1] === code)[0][0];
|
||||
const str = prop.split('_').join(' ');
|
||||
return `${str}`;
|
||||
}
|
||||
|
||||
function isValidId(id) {
|
||||
return Object.values(ids).includes(id);
|
||||
}
|
||||
function idToString(id) {
|
||||
if(!isValidId(id)) {
|
||||
return `invalid message id`;
|
||||
}
|
||||
const prop = Object.entries(ids).filter(e => e[1] === id)[0][0];
|
||||
const str = prop.split('_').join(' ');
|
||||
return `${str}`;
|
||||
}
|
||||
|
||||
function readResponse(msg) {
|
||||
// response to write
|
||||
const channel = readChannel(msg);
|
||||
const id = readId(msg);
|
||||
const toId = msg[4];
|
||||
const code = msg[5];
|
||||
return { channel, id, toId, code };
|
||||
}
|
||||
function readEvent(msg) {
|
||||
const channel = readChannel(msg);
|
||||
const code = msg[5];
|
||||
return { channel, code };
|
||||
}
|
||||
|
||||
|
||||
function isResponse(msg) {
|
||||
return readId(msg) === message.ids.channelResponse;
|
||||
}
|
||||
function isRequestedResponse(msg) {
|
||||
return [message.ids.channelId,
|
||||
message.ids.channelStatus,
|
||||
message.ids.ANTVersion,
|
||||
message.ids.capabilities,
|
||||
message.ids.serialNumber
|
||||
].includes(readId(msg));
|
||||
}
|
||||
function isBroadcast(msg) {
|
||||
return readId(msg) === message.ids.broascastData;
|
||||
}
|
||||
function isBroadcastExt(msg) {
|
||||
return readId(msg) === message.ids.broascastExtData;
|
||||
}
|
||||
function isAcknowledged(msg) {
|
||||
return readId(msg) === message.ids.acknowledgedData;
|
||||
}
|
||||
function isBurst(msg) {
|
||||
return readId(msg) === message.ids.burstData;
|
||||
}
|
||||
function isBurstAdv(msg) {
|
||||
return readId(msg) === message.ids.burstAdvData;
|
||||
}
|
||||
function isEvent(msg) {
|
||||
return readId(msg) === message.ids.channelEvent;
|
||||
}
|
||||
function isSerialError(msg) {
|
||||
return readId(msg) === message.ids.serialError;
|
||||
}
|
||||
function isChannelId(msg) {
|
||||
return message.ids.channelId === readId(msg);
|
||||
}
|
||||
function isChannelStatus(msg) {
|
||||
return message.ids.channelStatus === readId(msg);
|
||||
}
|
||||
function isANTVersion(msg) {
|
||||
return message.ids.ANTVersion === readId(msg);
|
||||
}
|
||||
function isCapabilities(msg) {
|
||||
return message.ids.capabilities === readId(msg);
|
||||
}
|
||||
function isSerialNumber(msg) {
|
||||
return message.ids.serialNumber === readId(msg);
|
||||
}
|
||||
|
||||
const startsWithSync = (data) => readSync(data) === 0xA4;
|
||||
const isFullLength = (data) => readLength(data) !== (data.length + 3);
|
||||
|
||||
function isValid(data) {
|
||||
if(!startsWithSync(data)) return false;
|
||||
// if(!isFullLength(data)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function DataPage2(msg) {
|
||||
// HR Manufacturer Information (0x02)
|
||||
const manufacturerId = msg[5];
|
||||
const serialNumber = (msg[7] << 8) + (msg[6]);
|
||||
}
|
||||
function DataPage3(msg) {
|
||||
// HR Product Information (0x03)
|
||||
const hardware = msg[5];
|
||||
const software = msg[6];
|
||||
const model = msg[7];
|
||||
|
||||
return { hardware, software, model };
|
||||
}
|
||||
|
||||
function toBatteryPercentage(x) {
|
||||
if(x === 255) return 'not supported';
|
||||
if(x > 100) return '--';
|
||||
return x;
|
||||
}
|
||||
function DataPage7(msg) {
|
||||
// HR Battery Status (0x07)
|
||||
const level = toBatteryPercentage(msg[5]);
|
||||
const voltage = msg[6];
|
||||
const descriptive = msg[7];
|
||||
|
||||
return { level, voltage, descriptive };
|
||||
}
|
||||
|
||||
function HRPage(msg) {
|
||||
const page = msg[4] & 0b01111111; // just bit 0 to 6
|
||||
const pageChange = msg[4] << 7; // just bit 7
|
||||
const hrbEventTime = (msg[9] << 8) + msg[8];
|
||||
const hbCount = msg[10];
|
||||
const hr = msg[11];
|
||||
let specific = {};
|
||||
|
||||
if(page === 2) {
|
||||
specific = DataPage2(msg);
|
||||
}
|
||||
if(page === 3) {
|
||||
specific = DataPage3(msg);
|
||||
}
|
||||
if(page === 7) {
|
||||
specific = DataPage7(msg);
|
||||
}
|
||||
return { hr, page, hrbEventTime, hbCount, ...specific };
|
||||
}
|
||||
|
||||
function FECPage(msg) {
|
||||
const page = msg[4];
|
||||
if(page === 25) return dataPage25(msg);
|
||||
if(page === 16) return dataPage16(msg);
|
||||
return { page: 0 };
|
||||
}
|
||||
|
||||
const message = {
|
||||
UnassignChannel,
|
||||
AssignChannel,
|
||||
AssignChannelExt,
|
||||
ChannelId,
|
||||
ChannelPeriod,
|
||||
ChannelFrequency,
|
||||
SetNetworkKey,
|
||||
ResetSystem,
|
||||
OpenChannel,
|
||||
CloseChannel,
|
||||
SearchTimeout,
|
||||
LowPrioritySearchTimeout,
|
||||
EnableExtRxMessages,
|
||||
Request,
|
||||
Sleep,
|
||||
readChannelStatus,
|
||||
readChannelId,
|
||||
powerTarget,
|
||||
resistanceTarget,
|
||||
slopeTarget,
|
||||
ids,
|
||||
events,
|
||||
isResponse,
|
||||
isRequestedResponse,
|
||||
isBroadcast,
|
||||
isAcknowledged,
|
||||
isBurst,
|
||||
isBurstAdv,
|
||||
isBroadcastExt,
|
||||
isEvent,
|
||||
isSerialError,
|
||||
isValid,
|
||||
isChannelId,
|
||||
isChannelStatus,
|
||||
isANTVersion,
|
||||
isCapabilities,
|
||||
isSerialNumber,
|
||||
readSync,
|
||||
readLength,
|
||||
readId,
|
||||
readChannel,
|
||||
readResponse,
|
||||
readEvent,
|
||||
readExtendedData,
|
||||
readChannelId,
|
||||
readChannelStatus,
|
||||
readANTVersion,
|
||||
readCapabilities,
|
||||
readSerialNumber,
|
||||
eventCodeToString,
|
||||
idToString,
|
||||
HRPage,
|
||||
FECPage,
|
||||
};
|
||||
|
||||
export { message };
|
||||
216
ant/usb.js
@@ -1,216 +0,0 @@
|
||||
import { xf } from '../xf.js';
|
||||
import { first, empty, splitAt } from '../functions.js';
|
||||
import { message } from './message.js';
|
||||
|
||||
const DynastreamId = 4047;
|
||||
const ANT_USB_2_Stick_Id = 1008;
|
||||
const ANT_USB_m_Stick_Id = 1009;
|
||||
|
||||
function isAntStick(portInfo) {
|
||||
return portInfo.usbVendorId === DynastreamId;
|
||||
}
|
||||
|
||||
function includesAntStick(ports) {
|
||||
if(empty(ports)) return false;
|
||||
const antSticks = ports.filter(p => isAntStick(p.getInfo()));
|
||||
if(empty(antSticks)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAntStick(ports) {
|
||||
return first(ports.filter(p => isAntStick(p.getInfo())));
|
||||
}
|
||||
|
||||
class MessageTransformer {
|
||||
constructor() {
|
||||
this.container = [];
|
||||
}
|
||||
transform(chunk, controller) {
|
||||
const self = this;
|
||||
self.container.push(Array.from(chunk));
|
||||
self.container = self.container.flat();
|
||||
let msgs = splitAt(self.container, 164);
|
||||
self.container = msgs.pop();
|
||||
msgs.forEach(msg => controller.enqueue(msg));
|
||||
}
|
||||
flush(controller) {
|
||||
const self = this;
|
||||
controller.enqueue(self.container);
|
||||
}
|
||||
}
|
||||
|
||||
class USB {
|
||||
constructor(args) {
|
||||
this._port = {};
|
||||
this._reader = {};
|
||||
this._writer = {};
|
||||
this._baudRate = args.baudRate || this.defaultBaudRate();
|
||||
this._isOpen = this.defaultIsOpen();
|
||||
this.keepReading = true;
|
||||
this.onData = args.onData || ((x) => x);
|
||||
this.onReady = args.onReady || ((x) => x);
|
||||
}
|
||||
defaultBaudRate() { return 115200; }
|
||||
defaultIsOpen() { return false; }
|
||||
get baudRate() { return this._baudRate; }
|
||||
set baudRate(x) { this._baudRate = x; }
|
||||
get isOpen() { return this._isOpen; }
|
||||
set isOpen(x) { this._isOpen = x; }
|
||||
get port() { return this._port; }
|
||||
set port(x) {
|
||||
const self = this;
|
||||
if(self.isPort(x)) {
|
||||
this._port = x;
|
||||
} else {
|
||||
console.error(x);
|
||||
throw new Error(`USB trying to set invalid port.`);
|
||||
}
|
||||
}
|
||||
get reader() { return this._reader; }
|
||||
set reader(x) {
|
||||
const self = this;
|
||||
if(self.isReadable(x)) {
|
||||
this._reader = x;
|
||||
} else {
|
||||
console.error(x);
|
||||
throw new Error(`USB trying to set invalid reader.}`);
|
||||
}
|
||||
}
|
||||
get writer() { return this._writer; }
|
||||
set writer(x) {
|
||||
const self = this;
|
||||
if(self.isWritable(x)) {
|
||||
this._writer = x;
|
||||
} else {
|
||||
console.error(x);
|
||||
throw new Error(`USB trying to set invalid writer.`);
|
||||
}
|
||||
}
|
||||
isPort(x) {
|
||||
return ('readable' in x) && ('writable' in x);
|
||||
}
|
||||
isWritable(x) {
|
||||
return (x instanceof WritableStream) || (x instanceof WritableStreamDefaultWriter);
|
||||
}
|
||||
isReadable(x) {
|
||||
return (x instanceof ReadableStream) || (x instanceof ReadableStreamDefaultReader);
|
||||
}
|
||||
async init() {
|
||||
const self = this;
|
||||
if(!(self.isAvailable())) {
|
||||
self.onNotAvailable();
|
||||
return;
|
||||
}
|
||||
|
||||
xf.sub('ui:ant:switch', async function(e) {
|
||||
if(self.isOpen) {
|
||||
await self.close();
|
||||
} else {
|
||||
self.port = await self.requestAnt();
|
||||
self.open();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('connect', e => {
|
||||
self.onConnect(e);
|
||||
self.restore();
|
||||
}, navigator.serial);
|
||||
|
||||
xf.sub('disconnect', e => {
|
||||
self.onDisconnect(e);
|
||||
}, navigator.serial);
|
||||
|
||||
self.restore();
|
||||
}
|
||||
isAvailable() {
|
||||
const self = this;
|
||||
return 'serial' in navigator;
|
||||
}
|
||||
onNotAvailable() {
|
||||
const self = this;
|
||||
console.warn('ANT+ usb support is not available on this browser');
|
||||
}
|
||||
async onConnect(e) {
|
||||
const self = this;
|
||||
const port = e.target;
|
||||
const info = port.getInfo();
|
||||
if(isAntStick(info)) {
|
||||
console.log('ANT+ usb connected');
|
||||
}
|
||||
}
|
||||
onDisconnect(e) {
|
||||
const self = this;
|
||||
const port = e.target;
|
||||
const info = port.getInfo();
|
||||
if(isAntStick(info)) {
|
||||
console.log('ANT+ usb disconnected');
|
||||
xf.dispatch('ant:disconnected');
|
||||
}
|
||||
}
|
||||
async requestAnt() {
|
||||
const self = this;
|
||||
const filter = [{usbVendorId: DynastreamId}];
|
||||
const port = await navigator.serial.requestPort({filters: filter});
|
||||
return port;
|
||||
}
|
||||
async getKnownAnt() {
|
||||
const self = this;
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if(includesAntStick(ports)) {
|
||||
self.port = getAntStick(ports);
|
||||
console.log(`ANT+ stick found ${self.port}`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn('ANT+ stick not found');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async restore() {
|
||||
const self = this;
|
||||
const hasAnt = await self.getKnownAnt();
|
||||
if(hasAnt) { self.open(); }
|
||||
}
|
||||
async open() {
|
||||
const self = this;
|
||||
await self.port.open({ baudRate: 115200 });
|
||||
self.writer = self.port.writable.getWriter();
|
||||
self.isOpen = true;
|
||||
self.onReady();
|
||||
xf.dispatch('usb:ready');
|
||||
xf.dispatch('ant:connected');
|
||||
|
||||
self.read();
|
||||
}
|
||||
async close() {
|
||||
const self = this;
|
||||
self.keepReading = false;
|
||||
self.isOpen = false;
|
||||
await self.reader.cancel();
|
||||
xf.dispatch('ant:disconnected');
|
||||
}
|
||||
async read() {
|
||||
const self = this;
|
||||
while (self.port.readable && self.keepReading) {
|
||||
self.reader = self.port.readable.pipeThrough(new TransformStream(new MessageTransformer())).getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await self.reader.read();
|
||||
if (done) { break; }
|
||||
self.onData(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`ant+ usb reader error: ${error}`);
|
||||
} finally {
|
||||
self.reader.releaseLock();
|
||||
}
|
||||
}
|
||||
self.writer.releaseLock();
|
||||
await self.port.close();
|
||||
}
|
||||
async write(buffer) {
|
||||
const self = this;
|
||||
return await self.writer.write(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export { USB };
|
||||
@@ -1,83 +0,0 @@
|
||||
import { xf } from '../xf.js';
|
||||
import { services } from './services.js';
|
||||
import { Device } from './device.js';
|
||||
import { FTMS } from './ftms/ftms.js';
|
||||
import { FECBLE } from './fec-over-ble.js';
|
||||
|
||||
class Controllable {
|
||||
constructor(args) {
|
||||
this.device = new Device({filters: [{services: [services.fitnessMachine.uuid]},
|
||||
{services: [services.fecOverBle.uuid]}],
|
||||
optionalServices: [services.deviceInformation.uuid],
|
||||
name: args.name});
|
||||
this.protocol = {};
|
||||
}
|
||||
isConnected() {
|
||||
let self = this;
|
||||
if(self.device.connected === undefined) return false;
|
||||
return self.device.connected;
|
||||
}
|
||||
async connect() {
|
||||
let self = this;
|
||||
|
||||
await self.device.connect();
|
||||
|
||||
if(self.device.hasService(services.fitnessMachine.uuid)) {
|
||||
self.protocol = new FTMS({device: self.device,
|
||||
onPower: self.onPower,
|
||||
onCadence: self.onCadence,
|
||||
onSpeed: self.onSpeed,
|
||||
onConfig: self.onConfig });
|
||||
await self.protocol.connect();
|
||||
|
||||
} else if(self.device.hasService(services.fecOverBle.uuid)) {
|
||||
console.log('Controllable: falling back to FE-C over BLE.');
|
||||
self.protocol = new FECBLE({device: self.device,
|
||||
onPower: self.onPower,
|
||||
onCadence: self.onCadence,
|
||||
onSpeed: self.onSpeed,
|
||||
onConfig: self.onConfig });
|
||||
self.protocol.connect();
|
||||
} else {
|
||||
console.error('Controllable: no FTMS or BLE over FE-C.');
|
||||
}
|
||||
}
|
||||
async disconnect() {
|
||||
let self = this;
|
||||
this.device.disconnect();
|
||||
}
|
||||
async setPowerTarget(power) {
|
||||
const self = this;
|
||||
if(self.isConnected()) {
|
||||
self.protocol.setPowerTarget(power);
|
||||
console.log(`set power target: ${power}`);
|
||||
}
|
||||
}
|
||||
async setResistanceTarget(level) {
|
||||
const self = this;
|
||||
if(self.isConnected()) {
|
||||
self.protocol.setResistanceTarget(level);
|
||||
console.log(`set resistance target: ${level}`);
|
||||
}
|
||||
}
|
||||
async setSlopeTarget(args) {
|
||||
const self = this;
|
||||
if(self.isConnected()) {
|
||||
self.protocol.setSlopeTarget(args);
|
||||
}
|
||||
}
|
||||
onConfig(args) {
|
||||
xf.dispatch('device:features', args.features);
|
||||
}
|
||||
onData(e) {
|
||||
const self = this;
|
||||
const dataview = e.target.value;
|
||||
}
|
||||
onPower(power) { xf.dispatch('device:pwr', power); }
|
||||
onSpeed(speed) { xf.dispatch('device:spd', speed); }
|
||||
onCadence(cadence) { xf.dispatch('device:cad', cadence); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export { Controllable };
|
||||
218
ble/device.js
@@ -1,218 +0,0 @@
|
||||
import { xf } from '../xf.js';
|
||||
import { dataViewToString } from '../functions.js';
|
||||
import { services } from './services.js';
|
||||
|
||||
|
||||
|
||||
class Device {
|
||||
constructor(args) {
|
||||
this.device = {};
|
||||
this.server = {};
|
||||
this.services = {};
|
||||
this.characteristics = {};
|
||||
this.name = args.name || 'device';
|
||||
this.control = false;
|
||||
this.connected = false;
|
||||
this.filters = args.filters; // service uuid -> services.fitnessMachine.uuid
|
||||
this.optionalServices = args.optionalServices || [];
|
||||
this.retry = 0;
|
||||
}
|
||||
async isBleAvailable() {
|
||||
let self = this;
|
||||
return await navigator.bluetooth.getAvailability();
|
||||
}
|
||||
async query() {
|
||||
let self = this;
|
||||
let deviceId = window.sessionStorage.getItem(self.name);
|
||||
let devices = await navigator.bluetooth.getDevices();
|
||||
let device = devices.filter( device => device.id === deviceId)[0];
|
||||
return device;
|
||||
}
|
||||
async request() {
|
||||
let self = this;
|
||||
return await navigator.bluetooth.requestDevice({filters: self.filters,
|
||||
optionalServices: self.optionalServices});
|
||||
}
|
||||
async connect() {
|
||||
let self = this;
|
||||
if(self.isBleAvailable()) {
|
||||
xf.dispatch(`${self.name}:connecting`);
|
||||
try {
|
||||
self.device = await self.request();
|
||||
self.server = await self.device.gatt.connect();
|
||||
await self.getPrimaryServices();
|
||||
window.sessionStorage.setItem(self.name, self.device.id);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
xf.dispatch(`${self.name}:disconnected`);
|
||||
} finally {
|
||||
|
||||
if('connected' in self.server) {
|
||||
if(self.server.connected) {
|
||||
self.connected = true;
|
||||
self.device.addEventListener('gattserverdisconnected', self.onDisconnect.bind(self));
|
||||
|
||||
xf.dispatch(`${self.name}:connected`, self.device);
|
||||
console.log(`Connected ${self.device.name} ${self.name}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
xf.dispatch(`device:ble:unavalilable`);
|
||||
console.warn('BLE is not available! You need to turn it on.');
|
||||
}
|
||||
}
|
||||
async disconnect() {
|
||||
let self = this;
|
||||
self.device.gatt.disconnect();
|
||||
self.onDisconnect();
|
||||
}
|
||||
onDisconnect() {
|
||||
let self = this;
|
||||
self.connected = false;
|
||||
xf.dispatch(`${self.name}:disconnected`);
|
||||
self.device.removeEventListener('gattserverdisconnected', e => e);
|
||||
console.log(`Disconnected ${self.device.name}.`);
|
||||
}
|
||||
async getPrimaryServices() {
|
||||
let self = this;
|
||||
let services = await self.server.getPrimaryServices();
|
||||
services.forEach( service => {
|
||||
self.services[service.uuid] = service;
|
||||
});
|
||||
// console.log(self.services);
|
||||
return self.services;
|
||||
}
|
||||
hasService(uuid) {
|
||||
let self = this;
|
||||
return !(self.services[uuid] === undefined);
|
||||
}
|
||||
async getService(service) {
|
||||
let self = this;
|
||||
if(self.hasService(service)) {
|
||||
return self.services[service];
|
||||
} else {
|
||||
self.services[service] =
|
||||
await self.server.getPrimaryService(service);
|
||||
return self.services[service];
|
||||
}
|
||||
}
|
||||
async getCharacteristic(service, characteristic) {
|
||||
let self = this;
|
||||
self.characteristics[characteristic] =
|
||||
await self.services[service].getCharacteristic(characteristic);
|
||||
}
|
||||
async getDescriptors(characteristic) {
|
||||
let self = this;
|
||||
let descriptors = await self.characteristics[characteristic].getDescriptors(characteristic);
|
||||
return descriptors;
|
||||
}
|
||||
async startNotifications(characteristic, handler) {
|
||||
let self = this;
|
||||
await self.characteristics[characteristic].startNotifications();
|
||||
self.characteristics[characteristic].addEventListener('characteristicvaluechanged', handler);
|
||||
console.log(`Notifications started on ${self.characteristics[characteristic].uuid}.`);
|
||||
}
|
||||
async stopNotifications(characteristic) {
|
||||
let self = this;
|
||||
let c = self.characteristics[characteristic];
|
||||
await c.stopNotifications();
|
||||
c.removeEventListener('characteristicvaluechanged', function(e) {
|
||||
console.log(`Notifications stopped on: ${c.uuid}`);
|
||||
});
|
||||
}
|
||||
async notify(service, characteristic, handler) {
|
||||
let self = this;
|
||||
|
||||
if(self.connected) {
|
||||
await self.getService(service);
|
||||
await self.getCharacteristic(service, characteristic);
|
||||
await self.startNotifications(characteristic, handler);
|
||||
}
|
||||
}
|
||||
async connectAndNotify(service, characteristic, handler) {
|
||||
let self = this;
|
||||
|
||||
await self.connect();
|
||||
|
||||
if(self.connected) {
|
||||
await self.getService(service);
|
||||
await self.getCharacteristic(service, characteristic);
|
||||
await self.startNotifications(characteristic, handler);
|
||||
}
|
||||
}
|
||||
async writeCharacteristic(characteristic, value, response = false) {
|
||||
let self = this;
|
||||
let res = undefined;
|
||||
try{
|
||||
res = await self.characteristics[characteristic].writeValue(value);
|
||||
} catch(e) {
|
||||
console.log(`ERROR: device.writeCharacteristic: ${e}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
async readCharacteristic(characteristic) {
|
||||
let self = this;
|
||||
let res = new DataView(new Uint8Array([0]).buffer);
|
||||
try{
|
||||
res = await self.characteristics[characteristic].readValue();
|
||||
} catch(e) {
|
||||
console.log(`ERROR: device.readCharacteristic: ${e}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
async deviceInformation() {
|
||||
const self = this;
|
||||
let manufacturerNameString = self.device.name;
|
||||
let modelNumberString = '';
|
||||
let firmwareRevisionString = '';
|
||||
|
||||
if(self.hasService(services.deviceInformation.uuid)) {
|
||||
await self.getCharacteristic(services.deviceInformation.uuid,
|
||||
services.deviceInformation.manufacturerNameString.uuid);
|
||||
await self.getCharacteristic(services.deviceInformation.uuid,
|
||||
services.deviceInformation.modelNumberString.uuid);
|
||||
await self.getCharacteristic(services.deviceInformation.uuid,
|
||||
services.deviceInformation.firmwareRevisionString.uuid);
|
||||
let manufacturerName =
|
||||
await self.readCharacteristic(services.deviceInformation.manufacturerNameString.uuid);
|
||||
|
||||
let modelNumber =
|
||||
await self.readCharacteristic(services.deviceInformation.modelNumberString.uuid);
|
||||
|
||||
let firmwareRevision =
|
||||
await self.readCharacteristic(services.deviceInformation.firmwareRevisionString.uuid);
|
||||
|
||||
manufacturerNameString = dataViewToString(manufacturerName);
|
||||
modelNumberString = dataViewToString(modelNumber);
|
||||
firmwareRevisionString = dataViewToString(firmwareRevision);
|
||||
}
|
||||
|
||||
self.info = {manufacturerNameString: manufacturerNameString,
|
||||
modelNumberString: modelNumberString,
|
||||
firmwareRevisionString: firmwareRevisionString,
|
||||
name: self.device.name};
|
||||
|
||||
xf.dispatch(`${self.name}:info`, self.info);
|
||||
return self.info;
|
||||
}
|
||||
async batteryService() {
|
||||
let self = this;
|
||||
|
||||
await self.getService(services.batteryService.uuid);
|
||||
await self.getCharacteristic(services.batteryService.uuid,
|
||||
services.batteryService.batteryLevel.uuid);
|
||||
let batteryLevel =
|
||||
await self.readCharacteristic(services.batteryService.batteryLevel.uuid);
|
||||
|
||||
batteryLevel = batteryLevel.getUint8(0, true);
|
||||
self.battery = batteryLevel;
|
||||
|
||||
console.log(batteryLevel);
|
||||
|
||||
xf.dispatch(`${self.name}:battery`, self.battery);
|
||||
return self.battery;
|
||||
}
|
||||
};
|
||||
|
||||
export { Device };
|
||||
@@ -1,217 +0,0 @@
|
||||
import { services } from './services.js';
|
||||
import { nthBitToBool, xor } from '../functions.js';
|
||||
|
||||
function decodePower(powerMSB, powerLSB) {
|
||||
return ((powerMSB & 0b00001111) << 8) + (powerLSB);
|
||||
}
|
||||
|
||||
function decoupleStatus(powerMSB) {
|
||||
return powerMSB >> 4;
|
||||
}
|
||||
|
||||
function decodeStatus(bits) {
|
||||
return {
|
||||
powerCalibration: nthBitToBool(bits, 0),
|
||||
resistanceCalibration: nthBitToBool(bits, 1),
|
||||
userConfiguration: nthBitToBool(bits, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function dataPage25(dataview) {
|
||||
// Specific Tr data, 0x19
|
||||
const updateEventCount = dataview.getUint8(5);
|
||||
const cadence = dataview.getUint8(6); // rpm
|
||||
const powerLSB = dataview.getUint8(9); // 8bit Power Lsb
|
||||
const powerMSB = dataview.getUint8(10); // 4bit Power Msb + 4bit Status
|
||||
const flags = dataview.getUint8(11);
|
||||
|
||||
const power = decodePower(powerMSB, powerLSB);
|
||||
const status = decoupleStatus(powerMSB);
|
||||
|
||||
return { power, cadence, status, page: 25 };
|
||||
}
|
||||
|
||||
function dataPage16(dataview) {
|
||||
// General FE data, 0x10
|
||||
const resolution = 0.001;
|
||||
const equipmentType = dataview.getUint8(5);
|
||||
let speed = dataview.getUint16(8, true);
|
||||
const flags = dataview.getUint8(11);
|
||||
// const distance = dataview.getUint8(7); // 255 rollover
|
||||
// const hr = dataview.getUint8(10); // optional
|
||||
speed = (speed * resolution * 3.6);
|
||||
return { speed, page: 16 };
|
||||
}
|
||||
|
||||
function dataMsg(dataview) {
|
||||
let sync = dataview.getUint8(0);
|
||||
let length = dataview.getUint8(1);
|
||||
let type = dataview.getUint8(2);
|
||||
let channel = dataview.getUint8(3);
|
||||
let dataPage = dataview.getUint8(4);
|
||||
|
||||
if(dataPage === 25) {
|
||||
return dataPage25(dataview);
|
||||
}
|
||||
if(dataPage === 16) {
|
||||
return dataPage16(dataview);
|
||||
}
|
||||
return { page: 0 };
|
||||
}
|
||||
|
||||
function dataPage48(resistance) {
|
||||
// Data Page 48 (0x30) – Basic Resistance
|
||||
const dataPage = 48;
|
||||
const unit = 0.5;
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, dataPage, true);
|
||||
view.setUint8(7, resistance / 0.5, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function dataPage49(power) {
|
||||
// Data Page 49 (0x31) – Target Power
|
||||
const dataPage = 49;
|
||||
const unit = 0.25;
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, dataPage, true);
|
||||
view.setUint16(6, power / unit, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function compansateGradeOffset(slope) {
|
||||
// slope is coming as -> 1.8% * 100 = 180
|
||||
// 0 = -200%, 20000 = 0%, 40000 = 200%
|
||||
return 20000 + (slope);
|
||||
}
|
||||
|
||||
// compansateGradeOffset(0) === 20000
|
||||
// compansateGradeOffset(1) === 20100
|
||||
// compansateGradeOffset(4.5) === 20450
|
||||
// compansateGradeOffset(10) === 21000
|
||||
|
||||
function dataPage51(slope) {
|
||||
// Data Page 51 (0x33) – Track Resistance
|
||||
const dataPage = 51;
|
||||
const gradeUnit = 0.01;
|
||||
const crrUnit = 5*Math.pow(10,-5); // 5x10^-5
|
||||
const grade = compansateGradeOffset(slope);
|
||||
const crr = 0xFF; // default value
|
||||
let buffer = new ArrayBuffer(8);
|
||||
let view = new DataView(buffer);
|
||||
|
||||
view.setUint8( 0, dataPage, true);
|
||||
view.setUint16(5, grade, true);
|
||||
view.setUint8( 7, crr, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function controlMessage(content, channel = 5) {
|
||||
const sync = 164;
|
||||
const length = 9;
|
||||
const type = 79; // Acknowledged 0x4F
|
||||
let buffer = new ArrayBuffer(13);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, sync, true);
|
||||
view.setUint8(1, length, true);
|
||||
view.setUint8(2, type, true);
|
||||
view.setUint8(3, channel, true);
|
||||
|
||||
let j = 4;
|
||||
for(let i = 0; i < 8; i++) {
|
||||
view.setUint8(j, content.getUint8(i), true);
|
||||
j++;
|
||||
}
|
||||
|
||||
const crc = xor(view);
|
||||
view.setUint8(12, crc, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function powerTargetMsg(power, channel = 5) {
|
||||
return controlMessage(dataPage49(power, channel));
|
||||
}
|
||||
function resistanceTargetMsg(level, channel = 5) {
|
||||
return controlMessage(dataPage48(level, channel));
|
||||
}
|
||||
function slopeTargetMsg(slope, channel = 5) {
|
||||
return controlMessage(dataPage51(slope, channel));
|
||||
}
|
||||
|
||||
class FECBLE {
|
||||
constructor(args) {
|
||||
this.device = args.device;
|
||||
this.info = {};
|
||||
this.features = {};
|
||||
this.status = {};
|
||||
this.onPower = args.onPower;
|
||||
this.onCadence = args.onCadence;
|
||||
this.onSpeed = args.onSpeed;
|
||||
this.onConfig = args.onConfig;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const self = this;
|
||||
await self.device.notify(services.fecOverBle.uuid,
|
||||
services.fecOverBle.fec2.uuid,
|
||||
self.onData.bind(self));
|
||||
|
||||
await self.device.getCharacteristic(services.fecOverBle.uuid,
|
||||
services.fecOverBle.fec3.uuid);
|
||||
|
||||
const features = {
|
||||
readings: ['Power', 'Speed', 'Cadence'],
|
||||
targets: ['Power', 'Resistance', 'Simulation'],
|
||||
params: {
|
||||
power: {min: 0, max: 4096, inc: 1},
|
||||
resistance: {min: 0, max: 100, inc: 1}
|
||||
}
|
||||
};
|
||||
|
||||
self.onConfig({ features });
|
||||
}
|
||||
async setPowerTarget(value) {
|
||||
const self = this;
|
||||
const msg = powerTargetMsg(value);
|
||||
const buffer = msg.buffer;
|
||||
let res =
|
||||
await self.device.writeCharacteristic(services.fecOverBle.fec3.uuid, buffer);
|
||||
}
|
||||
async setResistanceTarget(value) {
|
||||
const self = this;
|
||||
const msg = resistanceTargetMsg(value);
|
||||
const buffer = msg.buffer;
|
||||
let res =
|
||||
await self.device.writeCharacteristic(services.fecOverBle.fec3.uuid, buffer);
|
||||
}
|
||||
async setSlopeTarget(args) {
|
||||
const self = this;
|
||||
const msg = slopeTargetMsg(args.grade);
|
||||
const buffer = msg.buffer;
|
||||
let res =
|
||||
await self.device.writeCharacteristic(services.fecOverBle.fec3.uuid, buffer);
|
||||
}
|
||||
onData(e) {
|
||||
const self = this;
|
||||
const dataview = e.target.value;
|
||||
const data = dataMsg(dataview);
|
||||
if(data.page === 25) {
|
||||
self.onPower(data.power);
|
||||
self.onCadence(data.cadence);
|
||||
}
|
||||
if(data.page === 16) {
|
||||
self.onSpeed(data.speed);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export { FECBLE };
|
||||
183
ble/ftms/ftms.js
@@ -1,183 +0,0 @@
|
||||
import { xf } from '../../xf.js';
|
||||
|
||||
import { services } from '../services.js';
|
||||
|
||||
import { dataviewToIndoorBikeData } from './indoor-bike-data.js';
|
||||
|
||||
import { powerTarget,
|
||||
resistanceTarget,
|
||||
slopeTarget,
|
||||
simulationParameters,
|
||||
dataviewToControlPointResponse } from './control-point.js';
|
||||
|
||||
import { dataviewToFitnessMachineStatus } from './fitness-machine-status.js';
|
||||
|
||||
import { dataviewToFitnessMachineFeature } from './fitness-machine-feature.js';
|
||||
|
||||
import { dataviewToSupportedPowerRange,
|
||||
dataviewToSupportedResistanceLevelRange } from './supported.js';
|
||||
|
||||
class FTMS {
|
||||
constructor(args) {
|
||||
this.device = args.device;
|
||||
this.info = {};
|
||||
this.features = {};
|
||||
this.status = {};
|
||||
this.onPower = args.onPower;
|
||||
this.onSpeed = args.onSpeed;
|
||||
this.onCadence = args.onCadence;
|
||||
this.onConfig = args.onConfig;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const self = this;
|
||||
await self.config();
|
||||
await self.device.notify(services.fitnessMachine.uuid,
|
||||
services.fitnessMachine.indoorBikeData.uuid,
|
||||
self.onData.bind(self));
|
||||
await self.device.notify(services.fitnessMachine.uuid,
|
||||
services.fitnessMachine.fitnessMachineControlPoint.uuid,
|
||||
self.onControlPoint.bind(self));
|
||||
await self.requestControl();
|
||||
}
|
||||
async config() {
|
||||
const self = this;
|
||||
const info = await self.device.deviceInformation();
|
||||
let features = await self.getFitnessMachineFeature();
|
||||
features = await self.getTargetParams(features);
|
||||
self.features = features;
|
||||
self.info = info;
|
||||
|
||||
self.onConfig({ features });
|
||||
|
||||
await self.subFitnessMachineStatus();
|
||||
}
|
||||
async setPowerTarget(power) {
|
||||
const self = this;
|
||||
const msg = powerTarget(power);
|
||||
const buffer = msg.buffer;
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
|
||||
await self.device.writeCharacteristic(uuid, buffer);
|
||||
}
|
||||
async setResistanceTarget(level) {
|
||||
const self = this;
|
||||
const msg = resistanceTarget(level);
|
||||
const buffer = msg.buffer;
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
|
||||
await self.device.writeCharacteristic(uuid, buffer);
|
||||
}
|
||||
async setSlopeTarget(args) {
|
||||
const self = this;
|
||||
const msg = slopeTarget(args);
|
||||
const buffer = msg.buffer;
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
|
||||
await self.device.writeCharacteristic(uuid, buffer);
|
||||
}
|
||||
onData(e) {
|
||||
const self = this;
|
||||
let dataview = e.target.value;
|
||||
let data = dataviewToIndoorBikeData(dataview);
|
||||
|
||||
self.onPower(data.power);
|
||||
self.onSpeed(data.speed);
|
||||
self.onCadence(data.cadence);
|
||||
|
||||
return data;
|
||||
}
|
||||
async requestControl() {
|
||||
const self = this;
|
||||
const opCode = new Uint8Array([0x00]);
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
|
||||
return await self.device.writeCharacteristic(uuid, opCode.buffer);
|
||||
}
|
||||
onControlPoint (e) {
|
||||
const dataview = e.target.value;
|
||||
const res = dataviewToControlPointResponse(dataview);
|
||||
// console.log(`on control point: ${res.responseCode} ${res.requestCode} ${res.resultCode} | ${res.response} : ${res.operation} : ${res.result}`);
|
||||
}
|
||||
async getTargetParams(feature) {
|
||||
const self = this;
|
||||
feature['params'] = {};
|
||||
|
||||
if(feature.targets.includes('Power')) {
|
||||
const range = await self.getSupportedPowerRange();
|
||||
feature.params['power'] = range;
|
||||
};
|
||||
if(feature.targets.includes('Resistance')) {
|
||||
const range = await self.getSupportedResistanceLevelRange();
|
||||
feature.params['resistance'] = range;
|
||||
}
|
||||
|
||||
return feature;
|
||||
}
|
||||
async getFitnessMachineFeature() {
|
||||
const self = this;
|
||||
const service = services.fitnessMachine.uuid;
|
||||
const characteristic = services.fitnessMachine.fitnessMachineFeature.uuid;
|
||||
|
||||
await self.device.getCharacteristic(service, characteristic);
|
||||
|
||||
const dataview = await self.device.readCharacteristic(characteristic);
|
||||
return dataviewToFitnessMachineFeature(dataview);
|
||||
}
|
||||
async getSupportedPowerRange() {
|
||||
const self = this;
|
||||
const service = services.fitnessMachine.uuid;
|
||||
const characteristic = services.fitnessMachine.supportedPowerRange.uuid;
|
||||
|
||||
await self.device.getCharacteristic(service, characteristic);
|
||||
|
||||
const dataview = await self.device.readCharacteristic(characteristic);
|
||||
return dataviewToSupportedPowerRange(dataview);
|
||||
}
|
||||
|
||||
async getSupportedResistanceLevelRange() {
|
||||
const self = this;
|
||||
const service = services.fitnessMachine.uuid;
|
||||
const characteristic = services.fitnessMachine.supportedResistanceLevelRange.uuid;
|
||||
|
||||
await self.device.getCharacteristic(service, characteristic);
|
||||
|
||||
const dataview = await self.device.readCharacteristic(characteristic);
|
||||
return dataviewToSupportedResistanceLevelRange(dataview);
|
||||
}
|
||||
async subFitnessMachineStatus() {
|
||||
const self = this;
|
||||
await self.device.notify(services.fitnessMachine.uuid,
|
||||
services.fitnessMachine.fitnessMachineStatus.uuid,
|
||||
self.onFitnessMachineStatus);
|
||||
}
|
||||
async onFitnessMachineStatus(e) {
|
||||
const self = this;
|
||||
const dataview = e.target.value;
|
||||
const status = dataviewToFitnessMachineStatus(dataview);
|
||||
self.status = status;
|
||||
return status;
|
||||
}
|
||||
async reset() {
|
||||
const self = this;
|
||||
const OpCode = 0x01;
|
||||
let buffer = new ArrayBuffer(1);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, OpCode, true);
|
||||
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
await self.device.writeCharacteristic(uuid, buffer);
|
||||
}
|
||||
async spinDownControl() {
|
||||
const self = this;
|
||||
const OpCode = 0x01;
|
||||
let buffer = new ArrayBuffer(1);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, OpCode, true);
|
||||
|
||||
const uuid = services.fitnessMachine.fitnessMachineControlPoint.uuid;
|
||||
await self.device.writeCharacteristic(uuid, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export { FTMS };
|
||||
45
ble/hrb.js
@@ -1,45 +0,0 @@
|
||||
import { xf } from '../xf.js';
|
||||
import { services } from './services.js';
|
||||
import { Device } from './device.js';
|
||||
import { hrs } from './hrs/hrs.js';
|
||||
|
||||
class Hrb {
|
||||
constructor(args) {
|
||||
this.device = new Device({filters: [{services: [services.heartRate.uuid]}],
|
||||
optionalServices: [services.deviceInformation.uuid,
|
||||
services.batteryService.uuid],
|
||||
name: args.name});
|
||||
|
||||
this.name = args.name;
|
||||
}
|
||||
async connect() {
|
||||
const self = this;
|
||||
await self.device.connectAndNotify(services.heartRate.uuid,
|
||||
services.heartRate.heartRateMeasurement.uuid,
|
||||
self.onHeartRateMeasurement.bind(self));
|
||||
await self.device.deviceInformation();
|
||||
await self.device.batteryService();
|
||||
}
|
||||
async disconnect() {
|
||||
const self = this;
|
||||
self.device.disconnect();
|
||||
}
|
||||
async startNotifications() {
|
||||
const self = this;
|
||||
self.device.notify(services.heartRate.uuid,
|
||||
services.heartRate.heartRateMeasurement.uuid,
|
||||
self.onHeartRateMeasurement);
|
||||
}
|
||||
stopNotifications() {
|
||||
const self = this;
|
||||
self.device.stopNotifications(services.heartRate.heartRateMeasurement.uuid);
|
||||
}
|
||||
onHeartRateMeasurement (e) {
|
||||
const self = this;
|
||||
const dataview = e.target.value;
|
||||
const data = hrs.dataviewToHeartRateMeasurement(dataview);
|
||||
xf.dispatch('device:hr', data.hr);
|
||||
}
|
||||
}
|
||||
|
||||
export { Hrb };
|
||||
@@ -1,36 +0,0 @@
|
||||
import { xf } from '../xf.js';
|
||||
import { services } from './services.js';
|
||||
import { Device } from './device.js';
|
||||
import { cps } from './cps/cps.js';
|
||||
|
||||
class PowerMeter {
|
||||
constructor(args) {
|
||||
this.device = new Device({filters: [{services: [services.cyclingPower.uuid]}],
|
||||
optionalServices: [services.deviceInformation.uuid,
|
||||
services.batteryService.uuid],
|
||||
name: args.name});
|
||||
this.cyclingPowerFeature = {};
|
||||
}
|
||||
isConnected() {
|
||||
let self = this;
|
||||
return self.device.connected;
|
||||
}
|
||||
async connect() {
|
||||
let self = this;
|
||||
await self.device.connectAndNotify(services.cyclingPower.uuid,
|
||||
services.cyclingPower.cyclingPowerMeasurement.uuid,
|
||||
self.onCyclingPowerMeasurementData);
|
||||
|
||||
await self.device.deviceInformation();
|
||||
}
|
||||
async getCyclingPowerFeature() {
|
||||
let self = this;
|
||||
}
|
||||
onCyclingPowerMeasurementData(e) {
|
||||
let dataview = e.target.value;
|
||||
let data = cps.dataviewToCyclingPowerMeasurement(dataview);
|
||||
xf.dispatch('pm:power', data.power);
|
||||
}
|
||||
}
|
||||
|
||||
export { PowerMeter };
|
||||
@@ -1,39 +0,0 @@
|
||||
const services = {
|
||||
fitnessMachine: {
|
||||
uuid: '00001826-0000-1000-8000-00805f9b34fb',
|
||||
indoorBikeData: {uuid: '00002ad2-0000-1000-8000-00805f9b34fb'},
|
||||
fitnessMachineControlPoint: {uuid: '00002ad9-0000-1000-8000-00805f9b34fb'},
|
||||
fitnessMachineFeature: {uuid: '00002acc-0000-1000-8000-00805f9b34fb'},
|
||||
supportedResistanceLevelRange: {uuid: '00002ad6-0000-1000-8000-00805f9b34fb'},
|
||||
supportedPowerRange: {uuid: '00002ad8-0000-1000-8000-00805f9b34fb'},
|
||||
fitnessMachineStatus: {uuid: '00002ada-0000-1000-8000-00805f9b34fb'}
|
||||
},
|
||||
cyclingPower: {
|
||||
uuid: '00001818-0000-1000-8000-00805f9b34fb',
|
||||
cyclingPowerMeasurement: {uuid: '00002a63-0000-1000-8000-00805f9b34fb'},
|
||||
cyclingPowerFeature: {uuid: '00002a65-0000-1000-8000-00805f9b34fb'},
|
||||
cyclingPowerControlPoint: {uuid: '00002a66-0000-1000-8000-00805f9b34fb'},
|
||||
sensorLocation: {uuid: '00002a5A-0000-1000-8000-00805f9b34fb'},
|
||||
},
|
||||
fecOverBle: {
|
||||
uuid: '6e40fec1-b5a3-f393-e0a9-e50e24dcca9e',
|
||||
fec2: {uuid: '6e40fec2-b5a3-f393-e0a9-e50e24dcca9e'},
|
||||
fec3: {uuid: '6e40fec3-b5a3-f393-e0a9-e50e24dcca9e'}
|
||||
},
|
||||
heartRate: {
|
||||
uuid: '0000180d-0000-1000-8000-00805f9b34fb',
|
||||
heartRateMeasurement: {uuid: '00002a37-0000-1000-8000-00805f9b34fb'}
|
||||
},
|
||||
batteryService: {
|
||||
uuid: '0000180f-0000-1000-8000-00805f9b34fb',
|
||||
batteryLevel: {uuid: '00002a19-0000-1000-8000-00805f9b34fb'}
|
||||
},
|
||||
deviceInformation: {
|
||||
uuid: '0000180a-0000-1000-8000-00805f9b34fb',
|
||||
manufacturerNameString: {uuid: '00002a29-0000-1000-8000-00805f9b34fb'},
|
||||
modelNumberString: {uuid: '00002a24-0000-1000-8000-00805f9b34fb'},
|
||||
firmwareRevisionString: {uuid: '00002a26-0000-1000-8000-00805f9b34fb'}
|
||||
}
|
||||
};
|
||||
|
||||
export { services };
|
||||
175
controllers.js
@@ -1,175 +0,0 @@
|
||||
import { xf } from './xf.js';
|
||||
import { FileHandler } from './file.js';
|
||||
import { workouts } from './workouts/workouts.js';
|
||||
import { zwo, intervalsToGraph } from './workouts/parser.js';
|
||||
import { RecordedData, RecordedLaps } from './test/mock.js';
|
||||
|
||||
function DeviceController(args) {
|
||||
const controllable = args.controllable;
|
||||
const hrb = args.hrb;
|
||||
const powerMeter = args.powerMeter;
|
||||
|
||||
const antFec = args.antFec;
|
||||
const antHrm = args.antHrm;
|
||||
|
||||
let watch = args.watch;
|
||||
let mode = 'erg';
|
||||
|
||||
xf.sub('db:mode', m => { mode = m; });
|
||||
|
||||
xf.sub('db:powerTarget', power => {
|
||||
if(mode === 'erg') {
|
||||
console.log();
|
||||
if(controllable.device.connected) {
|
||||
controllable.setPowerTarget(power);
|
||||
}
|
||||
if(antFec.connected) {
|
||||
antFec.setPowerTarget(power);
|
||||
}
|
||||
}
|
||||
});
|
||||
xf.sub('db:resistanceTarget', target => {
|
||||
let resistance = target;
|
||||
resistance = parseInt(resistance);
|
||||
if(controllable.device.connected) {
|
||||
controllable.setResistanceTarget(resistance);
|
||||
}
|
||||
if(antFec.connected) {
|
||||
antFec.setResistanceTarget(resistance);;
|
||||
}
|
||||
});
|
||||
xf.sub('db:slopeTarget', target => {
|
||||
let slope = target;
|
||||
slope *= 100;
|
||||
slope = parseInt(slope);
|
||||
if(controllable.device.connected) {
|
||||
controllable.setSlopeTarget({grade: slope});
|
||||
}
|
||||
if(antFec.connected) {
|
||||
antFec.setSlopeTarget({grade: slope});
|
||||
}
|
||||
});
|
||||
xf.sub('ui:workoutStart', e => { watch.startWorkout(); });
|
||||
xf.sub('ui:watchStart', e => { watch.start(); });
|
||||
xf.sub('workout:restore', e => { watch.restoreWorkout(); });
|
||||
xf.sub('ui:watchPause', e => { watch.pause(); });
|
||||
xf.sub('ui:watchResume', e => { watch.resume(); });
|
||||
xf.sub('ui:watchLap', e => { watch.lap(); });
|
||||
xf.sub('ui:watchStop', e => {
|
||||
const stop = confirm('Confirm Stop?');
|
||||
if(stop) {
|
||||
watch.stop();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('ui:controllable:switch', e => {
|
||||
if(controllable.device.connected) {
|
||||
controllable.disconnect();
|
||||
} else {
|
||||
controllable.connect();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('ui:hrb:switch', e => {
|
||||
if(hrb.device.connected) {
|
||||
hrb.disconnect();
|
||||
} else {
|
||||
hrb.connect();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('ui:pm:switch', e => {
|
||||
if(powerMeter.isConnected()) {
|
||||
powerMeter.disconnect();
|
||||
} else {
|
||||
powerMeter.connect();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('ui:antHrm:switch', e => {
|
||||
if(antHrm.connected) {
|
||||
antHrm.disconnect();
|
||||
} else {
|
||||
antHrm.connect();
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('ui:antFec:switch', e => {
|
||||
if(antFec.connected) {
|
||||
antFec.disconnect();
|
||||
} else {
|
||||
antFec.connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function FileController() {
|
||||
|
||||
xf.sub('db:workoutFile', workoutFile => {
|
||||
let fileHandler = new FileHandler();
|
||||
fileHandler.readFile(workoutFile);
|
||||
});
|
||||
}
|
||||
|
||||
function WorkoutController() {
|
||||
let ftp = 80;
|
||||
let index = 0;
|
||||
let workout = {};
|
||||
|
||||
xf.reg('db:ftp', e => {
|
||||
ftp = e.ftp;
|
||||
xf.dispatch('workouts:init', workouts); // ??
|
||||
xf.dispatch('ui:workout:set', 0); // ??
|
||||
});
|
||||
|
||||
xf.reg('file:upload:workout', e => {
|
||||
let graph = ``;
|
||||
let xml = e;
|
||||
let workout = zwo.parse(xml);
|
||||
|
||||
workout.intervals.forEach( interval => {
|
||||
interval.steps.forEach( step => {
|
||||
step.power = Math.round(ftp * step.power);
|
||||
});
|
||||
});
|
||||
|
||||
workout.id = index;
|
||||
if(workout.name === '' || workout.name === undefined) {
|
||||
workout.name = `Custom ${index}`;
|
||||
}
|
||||
if(workout.type === '' || workout.type === undefined) {
|
||||
workout.type = 'Custom';
|
||||
}
|
||||
if(workout.description === '' || workout.description === undefined) {
|
||||
workout.description = 'Custom workout';
|
||||
}
|
||||
workout.xml = xml;
|
||||
workout.graph = intervalsToGraph(workout.intervals, ftp);
|
||||
xf.dispatch('workout:add', workout);
|
||||
index += 1;
|
||||
});
|
||||
|
||||
xf.reg('workouts:init', e => {
|
||||
let workoutFiles = e;
|
||||
workoutFiles.forEach( w => {
|
||||
let workout = zwo.parse(w.xml);
|
||||
workout.intervals.forEach( interval => {
|
||||
interval.steps.forEach( step => {
|
||||
if(step.power >= 10) {
|
||||
step.power = step.power; // abs power
|
||||
} else {
|
||||
step.power = Math.round(ftp * step.power); // % FTP power
|
||||
}
|
||||
});
|
||||
});
|
||||
let graph = intervalsToGraph(workout.intervals, ftp);
|
||||
w.intervals = workout.intervals;
|
||||
w.id = index;
|
||||
w.graph = graph;
|
||||
xf.dispatch('workout:add', w);
|
||||
index += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { DeviceController, FileController, WorkoutController };
|
||||
314
db.js
@@ -1,314 +0,0 @@
|
||||
import { xf } from './xf.js';
|
||||
import { Encode } from './ant/fit.js';
|
||||
import { FileHandler } from './file.js';
|
||||
import { IDB } from './storage/idb.js';
|
||||
import { Session } from './storage/session.js';
|
||||
import { Workout } from './storage/workout.js';
|
||||
import { values } from './values.js';
|
||||
|
||||
import { avgOfArray, maxOfArray, sum,
|
||||
first, last, round, mps, kph,
|
||||
timeDiff, fixInRange, memberOf } from './functions.js';
|
||||
|
||||
let db = {
|
||||
pwr: 0, // controllable
|
||||
power: 0, // pm
|
||||
hr: 0,
|
||||
hrAnt: 0,
|
||||
cad: 0,
|
||||
spd: 0,
|
||||
distance: 0,
|
||||
elapsed: 0,
|
||||
lapTime: 0,
|
||||
|
||||
intervalIndex: 0,
|
||||
stepIndex: 0,
|
||||
intervalDuration: 0,
|
||||
stepDuration: 0,
|
||||
watchState: 'stopped',
|
||||
workoutState: 'stopped',
|
||||
|
||||
mode: 'erg',
|
||||
powerTarget: 0,
|
||||
powerTargetManual: 0,
|
||||
powerMin: 0,
|
||||
powerMax: 800,
|
||||
powerInc: 10,
|
||||
resistanceTarget: 0,
|
||||
resistanceMin: 0,
|
||||
resistanceMax: 100,
|
||||
resistanceInc: 10,
|
||||
slopeTarget: 0,
|
||||
slopeMin: 0,
|
||||
slopeMax: 30,
|
||||
slopeInc: 0.5,
|
||||
|
||||
records: [],
|
||||
lap: [],
|
||||
laps: [],
|
||||
lapStartTime: Date.now(),
|
||||
timestamp: Date.now(),
|
||||
inProgress: false,
|
||||
|
||||
ftp: values.ftp.defaultValue(),
|
||||
weight: values.weight.defaultValue(),
|
||||
theme: values.theme.defaultValue(),
|
||||
measurement: values.measurement.defaultValue(),
|
||||
page: values.page.defaultValue(),
|
||||
|
||||
workout: [],
|
||||
workoutFile: '',
|
||||
workouts: [],
|
||||
|
||||
points: [],
|
||||
|
||||
vibrate: true,
|
||||
vibrateBtn: 10,
|
||||
controllableFeatures: {},
|
||||
|
||||
antSearchList: [],
|
||||
antDeviceId: {},
|
||||
antHrm: {},
|
||||
antFec: {},
|
||||
};
|
||||
|
||||
xf.initDB(db);
|
||||
|
||||
|
||||
xf.reg('ui:ant:device:selected', (x, db) => {
|
||||
db.antDeviceId = db.antSearchList.filter(d => {
|
||||
return d.deviceNumber === parseInt(x);
|
||||
})[0];
|
||||
});
|
||||
function includesDevice(devices, id) {
|
||||
return devices.filter(d => d.deviceNumber === id.deviceNumber).length > 0;
|
||||
}
|
||||
xf.reg(`ant:search:device-found`, (x, db) => {
|
||||
if(includesDevice(db.antSearchList, x)) return;
|
||||
db.antSearchList.push(x);
|
||||
});
|
||||
|
||||
// Register DB Events
|
||||
xf.reg('device:hr', (x, db) => db.hr = x);
|
||||
xf.reg('device:pwr', (x, db) => db.pwr = x);
|
||||
xf.reg('device:spd', (x, db) => db.spd = x);
|
||||
xf.reg('device:cad', (x, db) => db.cad = x);
|
||||
xf.reg('device:dist', (x, db) => db.distance = x);
|
||||
xf.reg('pm:power', (x, db) => db.power = x);
|
||||
xf.reg('ant:hr', (x, db) => db.hrAnt = x);
|
||||
xf.reg('ant:fec:power', (x, db) => db.pwr = x);
|
||||
xf.reg('ant:fec:speed', (x, db) => db.spd = x);
|
||||
xf.reg('ant:fec:cadence', (x, db) => db.cadence = x);
|
||||
|
||||
xf.reg('ui:page', (x, db) => db.page = x);
|
||||
xf.reg('ui:ftp', (x, db) => db.ftp = x);
|
||||
xf.reg('ui:weight', (x, db) => db.weight = x);
|
||||
xf.reg('ui:theme', (_, db) => {
|
||||
db.theme = values.theme.switch(db.theme);
|
||||
});
|
||||
xf.reg('ui:measurement', (_, db) => {
|
||||
db.measurement = values.measurement.switch(db.measurement);
|
||||
});
|
||||
xf.reg('storage:set:ftp', (x, db) => {
|
||||
db.ftp = x;
|
||||
});
|
||||
xf.reg('storage:set:weight', (x, db) => db.weight = x);
|
||||
xf.reg('storage:set:theme', (x, db) => db.theme = x);
|
||||
xf.reg('storage:set:measurement', (x, db) => db.measurement = x);
|
||||
|
||||
xf.reg('ui:workoutFile', (x, db) => {
|
||||
db.workoutFile = x;
|
||||
console.log('ui:workoutFile');
|
||||
console.log(x);
|
||||
});
|
||||
xf.reg('ui:workout:set', (x, db) => {
|
||||
db.workout = db.workouts[x];
|
||||
});
|
||||
xf.reg('workout:add', (x, db) => db.workouts.push(x));
|
||||
|
||||
// Watch
|
||||
// >> watch.js
|
||||
// watch end
|
||||
|
||||
xf.reg('ui:activity:save', (x, db) => {
|
||||
let activity = Encode({data: db.records, laps: db.laps});
|
||||
let fileHandler = new FileHandler();
|
||||
fileHandler.downloadActivity(activity);
|
||||
});
|
||||
|
||||
|
||||
// Control Modes
|
||||
xf.reg('device:features', (features, db) => {
|
||||
// {targets: ['Power'],
|
||||
// readings: ['Power'],
|
||||
// params: {power: {min: 0, max: 800, inc: 1}}};
|
||||
console.log(features);
|
||||
|
||||
db.controllableFeatures = features;
|
||||
|
||||
db.powerMin = features.params.power.min;
|
||||
db.powerMax = features.params.power.max;
|
||||
db.powerInc = 10;
|
||||
|
||||
db.resistanceMin = features.params.resistance.min;
|
||||
db.resistanceMax = features.params.resistance.max;
|
||||
db.resistanceInc = 100;
|
||||
|
||||
db.slopeMin = 0;
|
||||
db.slopeMax = 45;
|
||||
db.slopeInc = 0.5;
|
||||
});
|
||||
|
||||
function validatePowerTarget(target, min, max) {
|
||||
return fixInRange(target, min, max);
|
||||
}
|
||||
function validateResistanceTarget(target, min, max) {
|
||||
return fixInRange(target, min, max);
|
||||
}
|
||||
function validateSlopeTarget(target, min, max) {
|
||||
return fixInRange(target, min, max);
|
||||
}
|
||||
|
||||
xf.reg('ui:power-target-set', (target, db) => {
|
||||
db.powerTarget = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
});
|
||||
xf.reg('ui:power-target-inc', (_, db) => {
|
||||
let target = db.powerTarget + db.powerInc;
|
||||
db.powerTarget = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
});
|
||||
xf.reg('ui:power-target-dec', (_, db) => {
|
||||
let target = db.powerTarget - db.powerInc;
|
||||
db.powerTarget = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
});
|
||||
|
||||
xf.reg('ui:power-target-manual-set', (target, db) => {
|
||||
let power = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
db.powerTargetManual = power;
|
||||
db.powerTarget = power;
|
||||
});
|
||||
xf.reg('ui:power-target-manual-inc', (_, db) => {
|
||||
let target = db.powerTargetManual + db.powerInc;
|
||||
let power = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
db.powerTargetManual = power;
|
||||
db.powerTarget = power;
|
||||
});
|
||||
xf.reg('ui:power-target-manual-dec', (_, db) => {
|
||||
let target = db.powerTargetManual - db.powerInc;
|
||||
let power = validatePowerTarget(target, db.powerMin, db.powerMax);
|
||||
db.powerTargetManual = power;
|
||||
db.powerTarget = power;
|
||||
});
|
||||
|
||||
xf.reg('ui:resistance-target-set', (target, db) => {
|
||||
db.resistanceTarget = validateResistanceTarget(target, db.resistanceMin, db.resistanceMax);
|
||||
});
|
||||
xf.reg('ui:resistance-target-inc', (_, db) => {
|
||||
let target = db.resistanceTarget + db.resistanceInc;
|
||||
db.resistanceTarget = validateResistanceTarget(target, db.resistanceMin, db.resistanceMax);
|
||||
});
|
||||
xf.reg('ui:resistance-target-dec', (_, db) => {
|
||||
let target = db.resistanceTarget - db.resistanceInc;
|
||||
db.resistanceTarget = validateResistanceTarget(target, db.resistanceMin, db.resistanceMax);
|
||||
});
|
||||
|
||||
xf.reg('ui:slope-target-set', (target, db) => {
|
||||
db.slopeTarget = validateSlopeTarget(target, db.slopeMin, db.slopeMax);
|
||||
});
|
||||
xf.reg('ui:slope-target-inc', (_, db) => {
|
||||
let target = db.slopeTarget + db.slopeInc;
|
||||
db.slopeTarget = validateSlopeTarget(target, db.slopeMin, db.slopeMax);
|
||||
});
|
||||
xf.reg('ui:slope-target-dec', (_, db) => {
|
||||
let target = db.slopeTarget - db.slopeInc;
|
||||
db.slopeTarget = validateSlopeTarget(target, db.slopeMin, db.slopeMax);
|
||||
});
|
||||
|
||||
xf.reg('ui:erg-mode', (e, db) => {
|
||||
db.mode = 'erg';
|
||||
xf.dispatch('ui:power-target-manual-set', db.powerTargetManual);
|
||||
// xf.dispatch('ui:power-target-set', db.powerTargetManual);
|
||||
});
|
||||
xf.reg('ui:resistance-mode', (e, db) => {
|
||||
db.mode = 'resistance';
|
||||
xf.dispatch('ui:resistance-target-set', db.resistanceTarget);
|
||||
});
|
||||
xf.reg('ui:slope-mode', (e, db) => {
|
||||
db.mode = 'slope';
|
||||
xf.dispatch('ui:slope-target-set', db.slopeTarget);
|
||||
});
|
||||
// Control Modes end
|
||||
|
||||
|
||||
|
||||
// Session
|
||||
let idb = new IDB();
|
||||
let session = {};
|
||||
|
||||
function dbToSession(db) {
|
||||
let session = {
|
||||
elapsed: db.elapsed,
|
||||
lapTime: db.lapTime,
|
||||
stepTime: db.stepTime,
|
||||
intervalIndex: db.intervalIndex,
|
||||
powerTarget: db.powerTarget,
|
||||
powerTargetManual: db.powerTargetManual,
|
||||
slopeTarget: db.slopeTarget,
|
||||
stepIndex: db.stepIndex,
|
||||
mode: db.mode,
|
||||
|
||||
watchState: db.watchState,
|
||||
workoutState: db.workoutState,
|
||||
workout: db.workout,
|
||||
|
||||
records: db.records,
|
||||
|
||||
page: db.page,
|
||||
// theme: db.theme,
|
||||
// weight: db.weight,
|
||||
// measurement: db.measurement,
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
xf.reg('app:start', async function (x, db) {
|
||||
await idb.open('store', 1, 'session');
|
||||
session = new Session({idb: idb, name: 'session'});
|
||||
await session.restore();
|
||||
xf.dispatch('db:ready');
|
||||
});
|
||||
|
||||
xf.reg('lock:beforeunload', (e, db) => {
|
||||
session.save(idb, dbToSession(db));
|
||||
});
|
||||
xf.reg('lock:release', (e, db) => {
|
||||
session.save(idb, dbToSession(db));
|
||||
});
|
||||
xf.reg(`session:restore`, (session, db) => {
|
||||
|
||||
// Restore DB state
|
||||
for(let prop in session) {
|
||||
if (session.hasOwnProperty(prop)) {
|
||||
db[prop] = session[prop];
|
||||
}
|
||||
}
|
||||
|
||||
// Start Workout with restored db state
|
||||
xf.dispatch('workout:restore');
|
||||
// Restore BLE Devices
|
||||
// db.controllable = session.controllable;
|
||||
// db.hrm = session.hrm;
|
||||
// console.log(session);
|
||||
});
|
||||
|
||||
xf.reg('file:download:activity', (e, db) => {
|
||||
// reset db session:
|
||||
db.records = [];
|
||||
db.resistanceTarget = 0;
|
||||
db.slopeTarget = 0;
|
||||
db.targetPwr = 0;
|
||||
});
|
||||
// Session end
|
||||
|
||||
// values.theme.init();
|
||||
|
||||
export { db };
|
||||
67
file.js
@@ -1,67 +0,0 @@
|
||||
import { dateToDashString } from './functions.js';
|
||||
import { xf } from './xf.js';
|
||||
|
||||
class FileHandler {
|
||||
constructor(args) {}
|
||||
async readTextFile(file) {
|
||||
let self = this;
|
||||
let reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
reader.onload = _ => {
|
||||
let res = reader.result;
|
||||
console.log(res);
|
||||
xf.dispatch('file:upload:workout', res);
|
||||
};
|
||||
reader.onerror = _ => {
|
||||
let err = reader.error;
|
||||
console.error(`Error reading local file: `);
|
||||
console.error(reader.error);
|
||||
};
|
||||
}
|
||||
async readBinaryFile() {
|
||||
self.unsupportedFormat();
|
||||
}
|
||||
unsupporedFormat() {
|
||||
console.warn(`.fit workout files and other binary formats are not yet supported!`);
|
||||
}
|
||||
readFile(file) {
|
||||
let self = this;
|
||||
let ext = file.name.split('.').pop();
|
||||
switch(ext) {
|
||||
case 'zwo': self.readTextFile(file); break;
|
||||
case 'erg': self.readTextFile(file); break;
|
||||
case 'mrc': self.readTextFile(file); break;
|
||||
case 'fit': self.readBinaryfile(file); break;
|
||||
default: self.unsupportedFormat(); break;
|
||||
}
|
||||
}
|
||||
saveFile() {
|
||||
let self = this;
|
||||
let a = document.createElement('a');
|
||||
document.body.appendChild(a);
|
||||
a.style = 'display: none';
|
||||
return function (blob, name) {
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
};
|
||||
|
||||
downloadActivity(activity) {
|
||||
let self = this;
|
||||
let blob = new Blob([activity], {type: 'application/octet-stream'});
|
||||
|
||||
self.saveFile()(blob, WorkoutFileName());
|
||||
xf.dispatch('file:download:activity');
|
||||
}
|
||||
}
|
||||
|
||||
function WorkoutFileName () {
|
||||
let now = new Date();
|
||||
return `workout-${dateToDashString(now)}.fit`;
|
||||
}
|
||||
|
||||
|
||||
export { FileHandler, WorkoutFileName };
|
||||
347
functions.js
@@ -1,347 +0,0 @@
|
||||
let sin = x => Math.sin(x);
|
||||
let cos = x => Math.cos(x);
|
||||
let arctan = x => Math.atan(x);
|
||||
let abs = x => Math.abs(x);
|
||||
let sqrt = x => Math.sqrt(x);
|
||||
let round = x => Math.round(x);
|
||||
let floor = x => Math.floor(x);
|
||||
let ceil = x => Math.ceil(x);
|
||||
let exp = x => Math.exp(x);
|
||||
let sqr = x => x * x;
|
||||
let avg = (x, y) => (x + y) / 2;
|
||||
let format = (x, precision = 1000) => round(x * precision) / precision;
|
||||
let mps = kph => format(kph / 3.6);
|
||||
let kph = mps => 3.6 * mps;
|
||||
let mToYd = m => 1.09361 * m;
|
||||
let mpsToMph = mps => 2.23694 * mps;
|
||||
let kmhToMph = kmh => 0.621371 * kmh;
|
||||
let kgToLbs = kg => parseInt(2.20462 * kg);
|
||||
let lbsToKg = lbs => (0.453592 * lbs);
|
||||
let nextToLast = xs => xs[xs.length - 2];
|
||||
|
||||
const empty = (arr) => { return ( (arr === undefined) || !(arr.length > 0)); };
|
||||
const delay = ms => new Promise(res => setTimeout(res, ms));
|
||||
const digits = n => Math.log(n) * Math.LOG10E + 1 | 0;
|
||||
const rand = (min = 0, max = 10) => Math.floor(Math.random() * (max - min + 1) + min);
|
||||
|
||||
let last = xs => xs[xs.length - 1];
|
||||
let first = xs => xs[0];
|
||||
let second = xs => xs[1];
|
||||
let third = xs => xs[2];
|
||||
|
||||
function memberOf(xs, y) {
|
||||
return xs.filter(x => x === y).length > 0;
|
||||
}
|
||||
|
||||
function isArray(x) {
|
||||
return Array.isArray(x);
|
||||
}
|
||||
function isObject(x) {
|
||||
return typeof x === 'object' && !isArray(x);
|
||||
}
|
||||
function conj(target, source) {
|
||||
return Object.assign(target, source);
|
||||
}
|
||||
|
||||
function avgOfArray(xs, prop = false) {
|
||||
if(prop !== false) {
|
||||
return xs.reduce( (acc,v,i) => acc+(v[prop]-acc)/(i+1), 0);
|
||||
} else {
|
||||
return xs.reduce( (acc,v,i) => acc+(v-acc)/(i+1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function maxOfArray(xs, prop = false) {
|
||||
if(prop !== false) {
|
||||
return xs.reduce( (acc,v,i) => v[prop] > acc ? v[prop] : acc, 0);
|
||||
} else {
|
||||
return xs.reduce( (acc,v,i) => v > acc ? v : acc, 0);
|
||||
}
|
||||
};
|
||||
|
||||
function sum(xs, prop = false) {
|
||||
if(prop !== false) {
|
||||
return xs.reduce( (acc,v,i) => acc + v[prop], 0);
|
||||
} else {
|
||||
return xs.reduce( (acc,v,i) => acc + v, 0);
|
||||
}
|
||||
};
|
||||
|
||||
function splitAt(xs, at) {
|
||||
let i = -1;
|
||||
return xs.reduce((acc, x) => {
|
||||
if((x === at) || (acc.length === 0 && x !== at)) {
|
||||
acc.push([x]); i++;
|
||||
} else {
|
||||
acc[i].push(x);
|
||||
}
|
||||
return acc;
|
||||
},[]);
|
||||
}
|
||||
|
||||
function parseNumber(n, type = 'Int') {
|
||||
let value = 0;
|
||||
if(type === 'Int') {
|
||||
value = parseInt(n || 0);
|
||||
} else {
|
||||
value = parseFloat(n || 0);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
function fixInRange(target, min, max) {
|
||||
if(target >= max) {
|
||||
return max;
|
||||
} else if(target < min) {
|
||||
return min;
|
||||
} else {
|
||||
return target;
|
||||
}
|
||||
};
|
||||
|
||||
function powerToZone(value, ftp = 256) {
|
||||
let name = 'one';
|
||||
let hex = '#636468';
|
||||
if(value < (ftp * 0.55)) {
|
||||
name = 'one';
|
||||
} else if(value < (ftp * 0.76)) {
|
||||
name = 'two';
|
||||
} else if(value < (ftp * 0.88)) {
|
||||
name = 'three';
|
||||
} else if(value < (ftp * 0.95)) {
|
||||
name = 'four';
|
||||
} else if(value < (ftp * 1.06)) {
|
||||
name = 'five';
|
||||
} else if (value < (ftp * 1.20)) {
|
||||
name = 'six';
|
||||
} else {
|
||||
name = 'seven';
|
||||
}
|
||||
return {name: name};
|
||||
}
|
||||
|
||||
function valueToHeight(max, value) {
|
||||
return 100 * (value/max);
|
||||
}
|
||||
|
||||
function hrToColor(value) {
|
||||
let color = 'gray';
|
||||
if(value < 100) {
|
||||
color = 'gray';
|
||||
} else if(value < 120) {
|
||||
color = 'blue';
|
||||
} else if(value < 160) {
|
||||
color = 'green';
|
||||
} else if(value < 175) {
|
||||
color = 'yellow';
|
||||
} else if(value < 190) {
|
||||
color = 'orange';
|
||||
} else {
|
||||
color = 'red';
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function dateToDashString(date) {
|
||||
const day = (date.getDate()).toString().padStart(2, '0');
|
||||
const month = (date.getMonth()+1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString();
|
||||
const hour = (date.getHours()).toString().padStart(2, '0');
|
||||
const minute = (date.getMinutes()).toString().padStart(2, '0');
|
||||
return `${day}-${month}-${year}-at-${hour}-${minute}h`;
|
||||
}
|
||||
|
||||
function timeDiff(timestamp1, timestamp2) {
|
||||
let difference = (timestamp1 / 1000) - (timestamp2 / 1000);
|
||||
return round(abs(difference));
|
||||
};
|
||||
|
||||
function secondsToHms(elapsed, compact = false) {
|
||||
let hour = Math.floor(elapsed / 3600);
|
||||
let min = Math.floor(elapsed % 3600 / 60);
|
||||
let sec = elapsed % 60;
|
||||
let sD = (sec < 10) ? `0${sec}` : `${sec}`;
|
||||
let mD = (min < 10) ? `0${min}` : `${min}`;
|
||||
let hD = (hour < 10) ? `0${hour}` : `${hour}`;
|
||||
let hDs = (hour < 10) ? `${hour}` : `${hour}`;
|
||||
let res = ``;
|
||||
if(compact) {
|
||||
if(elapsed < 3600) {
|
||||
res = `${mD}:${sD}`;
|
||||
} else {
|
||||
res = `${hD}:${mD}:${sD}`;
|
||||
}
|
||||
} else {
|
||||
res = `${hD}:${mD}:${sD}`;
|
||||
}
|
||||
return res ;
|
||||
}
|
||||
|
||||
|
||||
function formatSpeed(value, measurement = 'metric') {
|
||||
if(measurement === 'imperial') {
|
||||
value = `${kmhToMph(value).toFixed(1)}`;
|
||||
} else {
|
||||
value = `${(value).toFixed(1)}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDistance(meters, measurement = 'metric') {
|
||||
let value = `0`;
|
||||
let km = (meters / 1000);
|
||||
let miles = (meters / 1609.34);
|
||||
let yards = mToYd(meters);
|
||||
|
||||
if(measurement === 'imperial') {
|
||||
value = (yards < 1609.34) ? `${(mToYd(meters)).toFixed(0)} yd` : `${miles.toFixed(2)} mi`;
|
||||
} else {
|
||||
value = (meters < 1000) ? `${meters.toFixed(0)} m` : `${km.toFixed(2)} km`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toDecimalPoint (x, point = 2) {
|
||||
return Number((x).toFixed(point));
|
||||
}
|
||||
|
||||
function divisors(number) {
|
||||
let divisors = [1];
|
||||
for(let i=2; i < number/2; i++) {
|
||||
if(number % i === 0) { divisors.push(i); }
|
||||
}
|
||||
return divisors;
|
||||
}
|
||||
|
||||
function hexToString(str) {
|
||||
var j;
|
||||
var hexes = str.match(/.{1,4}/g) || [];
|
||||
var back = "";
|
||||
for(j = 0; j<hexes.length; j++) {
|
||||
back += String.fromCharCode(parseInt(hexes[j], 16));
|
||||
}
|
||||
return back;
|
||||
}
|
||||
|
||||
function stringToHex(str) {
|
||||
var hex, i;
|
||||
var result = "";
|
||||
for (i=0; i<str.length; i++) {
|
||||
hex = str.charCodeAt(i).toString(16);
|
||||
result += ("000"+hex).slice(-4);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hex (n) {
|
||||
let h = parseInt(n).toString(16).toUpperCase();
|
||||
if(h.length === 1) {
|
||||
h = '0'+ h;
|
||||
}
|
||||
return '0x' + h;
|
||||
}
|
||||
|
||||
function arrayToString(array) {
|
||||
return String.fromCharCode.apply(String, array);
|
||||
}
|
||||
|
||||
function dataViewToString (dataview) {
|
||||
let len = dataview.byteLength;
|
||||
let str = '';
|
||||
for(let i = 0; i < len; i++) {
|
||||
let value = dataview.getUint8(i, true);
|
||||
if(value === 0) {
|
||||
str += '';
|
||||
} else {
|
||||
str += hexToString(hex(value));
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const nthBit = (field, bit) => (field >> bit) & 1;
|
||||
const toBool = (bit) => !!(bit);
|
||||
const nthBitToBool = (field, bit) => toBool(nthBit(field, bit));
|
||||
|
||||
function xor(view) {
|
||||
let cs = 0;
|
||||
for (let i=0; i < view.byteLength; i++) {
|
||||
cs ^= view.getUint8(i);
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
|
||||
function exists(x) {
|
||||
if(x === null || x === undefined) {
|
||||
return false;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
function isSet(x, msg = 'Does not exist!') {
|
||||
if(x === null || x === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export {
|
||||
sin,
|
||||
cos,
|
||||
arctan,
|
||||
abs,
|
||||
sqr,
|
||||
exp,
|
||||
sqrt,
|
||||
mps,
|
||||
kph,
|
||||
mpsToMph,
|
||||
kmhToMph,
|
||||
kgToLbs,
|
||||
lbsToKg,
|
||||
avg,
|
||||
rand,
|
||||
digits,
|
||||
conj,
|
||||
first,
|
||||
second,
|
||||
third,
|
||||
last,
|
||||
nextToLast,
|
||||
empty,
|
||||
avgOfArray,
|
||||
maxOfArray,
|
||||
sum,
|
||||
splitAt,
|
||||
memberOf,
|
||||
delay,
|
||||
parseNumber,
|
||||
toDecimalPoint,
|
||||
divisors,
|
||||
fixInRange,
|
||||
round,
|
||||
floor,
|
||||
ceil,
|
||||
hexToString,
|
||||
stringToHex,
|
||||
hex,
|
||||
arrayToString,
|
||||
dataViewToString,
|
||||
|
||||
nthBit,
|
||||
toBool,
|
||||
nthBitToBool,
|
||||
|
||||
powerToZone,
|
||||
hrToColor,
|
||||
valueToHeight,
|
||||
dateToDashString,
|
||||
timeDiff,
|
||||
secondsToHms,
|
||||
formatDistance,
|
||||
formatSpeed,
|
||||
xor,
|
||||
exists,
|
||||
isSet
|
||||
};
|
||||
444
index.html
@@ -1,444 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#35363a" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="favicon-180.png">
|
||||
<link rel="icon" type="image/png" sizes="196x196" href="favicon-196.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||
|
||||
<title>Flux</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;700&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link href="css/flux.css" rel="stylesheet"/>
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
</head>
|
||||
|
||||
<body id="theme" class="dark-theme">
|
||||
<div class="wrapper">
|
||||
|
||||
<div id="device-chooser">
|
||||
<div class="device-chooser-list"></div>
|
||||
<div class="device-chooser-bootom-bar">
|
||||
<div class="status">Searching ...</div>
|
||||
<div class="chooser-btns">
|
||||
<button id="chooser-cancel-btn" class="btn">Cancel</button>
|
||||
<button id="chooser-pair-btn" class="btn">Pair</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="home-page">
|
||||
|
||||
<header class="switch-connections page-header cf">
|
||||
<div id="switch-controllable" class="switch-connection switch">
|
||||
<div class="switch--indicator off"></div>
|
||||
<div class="switch--label">Controllable</div>
|
||||
</div>
|
||||
|
||||
<div id="switch-hrm" class="switch-connection switch">
|
||||
<div class="switch--indicator off"></div>
|
||||
<div class="switch--label">HRM</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="data-tiles cf">
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Power</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="power-value" class="data-tile--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Interval Time</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="interval-time" class="data-tile--value">--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Heart Rate</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="heart-rate-value" class="data-tile--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Target</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="power-target" class="data-tile--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Elapsed Time</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="time" class="data-tile--value">--:--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-tile">
|
||||
<h2 class="data-tile--heading">Cadence</h2>
|
||||
<div class="data-tile--value-cont">
|
||||
<div id="cadence-value" class="data-tile--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-tiles cf">
|
||||
<div class="data-tile--small">
|
||||
<h2 class="data-tile-small--heading">Speed</h2>
|
||||
<div class="data-tile-small--value-cont">
|
||||
<div id="speed-value" class="data-tile-small--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-tile--small">
|
||||
<h2 class="data-tile-small--heading">Distance</h2>
|
||||
<div class="data-tile-small--value-cont">
|
||||
<div id="distance-value" class="data-tile-small--value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line"></div>
|
||||
|
||||
<div class="graphs">
|
||||
<div id="graph-power" class="graph">
|
||||
<h4 class="graph--heading">Power</h4>
|
||||
<div id="graph-power--cont" class="graph--cont cf"></div>
|
||||
</div>
|
||||
|
||||
<div class="line"></div>
|
||||
|
||||
<div id="graph-workout" class="graph">
|
||||
<h4 id="current-workout-name" class="graph--heading"></h4>
|
||||
<div id="graph-current-workout" class="graph-workout--cont"></div>
|
||||
</div>
|
||||
</div> <!-- end graphs -->
|
||||
|
||||
<div class="line"></div>
|
||||
|
||||
<div id="targets">
|
||||
<div id="mode-selector" class="mode-selector">
|
||||
<div id="erg-mode-btn" class="mode active">ERG</div>
|
||||
<div id="resistance-mode-btn" class="mode">Resistance</div>
|
||||
<div id="slope-mode-btn" class="mode">Slope</div>
|
||||
</div>
|
||||
<div id="erg-mode-controls" class="erg-mode mode-controls">
|
||||
<header>
|
||||
<!-- <div class="h3">Power</div> -->
|
||||
<div id="erg-mode-params" class="t4">0 to 0 W</div>
|
||||
</header>
|
||||
<div id="power-control-btn" class="number-btn">
|
||||
<div class="number-btn">
|
||||
<button id="power-target-dec" class="number-btn--dec number-btn--btn btn">-</button>
|
||||
<input id="power-target-input" class="number-btn--value input" value="0" autocomplete="off"/>
|
||||
<button id="power-target-inc" class="number-btn--inc number-btn--btn btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end erg mode -->
|
||||
<div id="resistance-mode-controls" class="resistance-mode mode-controls">
|
||||
<header>
|
||||
<!-- <div class="h3">Resistance</div> -->
|
||||
<div id="resistance-mode-params" class="t4">0 to 0</div>
|
||||
</header>
|
||||
<div id="resistance-control-btn" class="number-btn">
|
||||
<div class="number-btn">
|
||||
<button id="resistance-target-dec" class="number-btn--dec number-btn--btn btn">-</button>
|
||||
<input id="resistance-target-input" class="number-btn--value input" value="0" autocomplete="off"/>
|
||||
<button id="resistance-target-inc" class="number-btn--inc number-btn--btn btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end resistance mode -->
|
||||
<div id="slope-mode-controls" class="slope-mode mode-controls">
|
||||
<header>
|
||||
<!-- <div class="h3">Slope</div> -->
|
||||
<div id="slope-mode-params" class="t4">0 to 0 %</div>
|
||||
</header>
|
||||
<div id="slope-control-btn" class="number-btn">
|
||||
<div class="number-btn">
|
||||
<button id="slope-target-dec" class="number-btn--dec number-btn--btn btn">-</button>
|
||||
<input id="slope-target-input" class="number-btn--value input" value="0" autocomplete="off"/>
|
||||
<button id="slope-target-inc" class="number-btn--inc number-btn--btn btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end slope mode -->
|
||||
</div> <!-- end targets -->
|
||||
</div> <!-- page -->
|
||||
|
||||
<div class="page" id="workouts-page">
|
||||
<div class="workouts-list" id="workouts">
|
||||
<header class="page-header">
|
||||
<h2 class="t2">Workouts</h2>
|
||||
</header>
|
||||
|
||||
<div class="buildin-workouts">
|
||||
<div class="list"> </div>
|
||||
</div>
|
||||
|
||||
<div class="workout-load-file">
|
||||
<h2 class="h2">Load workout:</h2>
|
||||
<div class="file-btn">
|
||||
<label for="workout-file" class="file-btn-label">Select</label>
|
||||
<input id="workout-file" class="file-btn-native" name="workout-file" type="file" value=""/>
|
||||
</div>
|
||||
</div>
|
||||
<br/><br/><br/>
|
||||
</div>
|
||||
</div> <!-- page -->
|
||||
<div class="page" id="settings-page">
|
||||
<header class="page-header">
|
||||
<h2 class="t2">Settings</h2>
|
||||
</header>
|
||||
<div class="settings-tiles">
|
||||
<div id="ftp-settings" class="settings-tile">
|
||||
<label for="ftp-value" class="settings-tile-label">FTP</label>
|
||||
<input id="ftp-value" class="settings-tile-input" name="ftp-value" type="number" value="200"/>
|
||||
<div id="ftp-btn" class="settings-tile-btn">Set</div>
|
||||
</div>
|
||||
<div id="weight-settings" class="settings-tile">
|
||||
<label id="weight-label" for="weight-value" class="settings-tile-label">kg</label>
|
||||
<input id="weight-value" class="settings-tile-input" name="weight-value" type="number" value="75"/>
|
||||
<div id="weight-btn" class="settings-tile-btn">Set</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-btn-row">
|
||||
<div id="theme-settings" class="settings-switch-btn-cont">
|
||||
<div id="theme-btn" class="settings-switch-bnt">Dark</div>
|
||||
</div>
|
||||
<div id="msystem-settings" class="settings-switch-btn-cont">
|
||||
<div id="measurement-btn" class="settings-switch-bnt">Metric</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row border">
|
||||
<div class="settings-card controllable">
|
||||
<div class="device-controls">
|
||||
<div id="controllable-settings-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div id="controllable-settings-name" class="text t2">Controllable</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<div class="info-cell">
|
||||
<h3 class="">Model</h3>
|
||||
<div class="value">
|
||||
<span id="controllable-settings-manufacturer">---</span>
|
||||
<span id="controllable-settings-model"></span>
|
||||
<span id="controllable-settings-firmware"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Power</h3>
|
||||
<div class="value" id="controllable-settings-power">-- W</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Cadence</h3>
|
||||
<div class="value" id="controllable-settings-cadence">-- rpm</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Speed</h3>
|
||||
<div class="value" id="controllable-settings-speed">-- km/h</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Distance</h3>
|
||||
<div class="value" id="controllable-settings-distance">-- km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row border">
|
||||
<div class="settings-card hr">
|
||||
<div class="device-controls">
|
||||
<div id="hrb-settings-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div id="hrb-settings-name" class="text t2">HRM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<div class="info-cell">
|
||||
<h3 class="">Model</h3>
|
||||
<div class="value">
|
||||
<span id="hrb-settings-manufacturer">---</span>
|
||||
<span id="hrb-settings-model"></span>
|
||||
<span id="hrb-settings-firmware"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Heart Rate</h3>
|
||||
<div class="value" id="hrb-settings-value">-- bpm</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Battery</h3>
|
||||
<div class="value" id="hrb-settings-battery">--- %</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row border">
|
||||
<div class="settings-card power-meter">
|
||||
<div class="device-controls">
|
||||
<div id="power-meter-settings-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div id="power-meter-settings-name" class="text t2">Power Meter</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<div class="info-cell">
|
||||
<h3 class="">Model</h3>
|
||||
<div class="value">
|
||||
<span id="power-meter-settings-manufacturer">---</span>
|
||||
<span id="power-meter-settings-model"></span>
|
||||
<span id="power-meter-settings-firmware"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Power</h3>
|
||||
<div class="value" id="power-meter-settings-power">-- W</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<h3 class="">Cadence</h3>
|
||||
<div class="value" id="power-meter-settings-cadence">-- rpm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row border">
|
||||
<div class="settings-card hr">
|
||||
<div class="device-controls">
|
||||
<div id="enable-ant-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div class="text t2">ANT+</div>
|
||||
</div>
|
||||
<div id="hrm-ant-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div class="text t2">Ant+ HRM</div>
|
||||
</div>
|
||||
|
||||
<div id="fec-ant-btn" class="switch">
|
||||
<div class="indicator off"></div>
|
||||
<div class="text t2">ANT+ FEC</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connections">
|
||||
<h2 class="h2">Connections</h2>
|
||||
<div class="connection">
|
||||
<img class="connection-icon" alt="GC" src="images/connections/garmin-connect.jpg"/>
|
||||
<div class="connection-content">
|
||||
<h3 class="h3">Garmin</h3>
|
||||
<a class="a t2" href="https://connect.garmin.com/modern/import-data" target="_blank">Garmin Connect Import Page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="version">
|
||||
<p class="t4">Version: 0.0.5</p>
|
||||
</div>
|
||||
</div> <!-- page -->
|
||||
|
||||
<div class="fixed-bottom">
|
||||
<div id="controls" class="controls">
|
||||
<div id="watch" class="watch cf">
|
||||
<div id="workout-name">Free ride</div>
|
||||
<div id="start-workout" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M10 16.5l6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48
|
||||
10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="watch-start" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M8 5v14l11-7L8 5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div id="watch-pause" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div id="watch-lap" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M15 1H9v2h6V1zm-4
|
||||
13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42
|
||||
1.42C16.07 4.74 14.12 4 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9 9-4.03
|
||||
9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7
|
||||
7-7 7 3.13 7 7-3.13 7-7 7z"/>
|
||||
</div>
|
||||
|
||||
<div id="watch-stop" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M6 6h12v12H6V6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div id="activity-save" class="control--btn">
|
||||
<svg class="control--btn--icon" xmlns="http://www.w3.org/2000/svg"
|
||||
height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M19 12v7H5v-7H3v9h18v-9h-2zm-6 .67l2.59-2.58L17
|
||||
11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2v9.67z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end controls -->
|
||||
|
||||
<div id="menu" class="menu">
|
||||
<div class="menu--row">
|
||||
<div id="settings-tab-btn" class="menu--btn data-index="2">
|
||||
<svg class="menu--btn--icon" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path class="menu--btn--icon--fill" d="M19.44 12.99l-.01.02c.04-.33.08-.67.08-1.01
|
||||
0-.34-.03-.66-.07-.99l.01.02 2.44-1.92-2.43-4.22-2.87
|
||||
1.16.01.01c-.52-.4-1.09-.74-1.71-1h.01L14.44 2H9.57l-.44
|
||||
3.07h.01c-.62.26-1.19.6-1.71 1l.01-.01-2.88-1.17-2.44 4.22 2.44
|
||||
1.92.01-.02c-.04.33-.07.65-.07.99 0 .34.03.68.08 1.01l-.01-.02-2.1
|
||||
1.65-.33.26 2.43 4.2 2.88-1.15-.02-.04c.53.41 1.1.75 1.73 1.01h-.03L9.58
|
||||
22h4.85s.03-.18.06-.42l.38-2.65h-.01c.62-.26 1.2-.6 1.73-1.01l-.02.04
|
||||
2.88 1.15 2.43-4.2s-.14-.12-.33-.26l-2.11-1.66zM12 15.5c-1.93
|
||||
0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/>
|
||||
</svg>
|
||||
<div class="menu--btn--label">Settings</div>
|
||||
</div>
|
||||
<div id="home-tab-btn" class="menu--btn active" data-index="0">
|
||||
<svg class="menu--btn--icon" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path class="menu--btn--icon--fill" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8h5z" />
|
||||
</svg>
|
||||
<div class="menu--btn--label">Home</div>
|
||||
</div>
|
||||
<div id="workouts-tab-btn" class="menu--btn" data-index="1">
|
||||
<svg class="menu--btn--icon" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path class="menu--btn--icon--fill" fill="#aaa" d="M4 10h12v2H4zm0-4h12v2H4zm0 8h8v2H4zm10 0v6l5-3z"/>
|
||||
</svg>
|
||||
<div class="menu--btn--label">Workouts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end menu -->
|
||||
|
||||
</div> <!-- end fixed-bottom -->
|
||||
|
||||
</div> <!-- end wrapper -->
|
||||
</body>
|
||||
|
||||
<script type="module" src="index.js"></script>
|
||||
</html>
|
||||
62
index.js
@@ -1,62 +0,0 @@
|
||||
import { xf } from './xf.js';
|
||||
import { db } from './db.js';
|
||||
import { Controllable } from './ble/controllable.js';
|
||||
import { PowerMeter } from './ble/power-meter.js';
|
||||
import { Hrb } from './ble/hrb.js';
|
||||
import { Watch } from './watch.js';
|
||||
import { WakeLock } from './lock.js';
|
||||
import { Views } from './views.js';
|
||||
import { ant, AntHrm, AntFec } from './ant/ant.js';
|
||||
import { DeviceController,
|
||||
FileController,
|
||||
WorkoutController } from './controllers.js';
|
||||
import { FileHandler } from './file.js';
|
||||
import { Vibrate } from './vibrate.js';
|
||||
import { DataMock } from './test/mock.js';
|
||||
|
||||
'use strict';
|
||||
|
||||
function startServiceWroker() {
|
||||
if('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js')
|
||||
.then(reg => {
|
||||
console.log(`SW: register success: ${reg}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(`SW: register error: ${err}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const hrb = new Hrb({name: 'hrb'});
|
||||
const flux = new Controllable({name: 'controllable'});
|
||||
const pm = new PowerMeter({name: 'pm'});
|
||||
|
||||
const antHrm = new AntHrm({});
|
||||
const antFec = new AntFec({});
|
||||
|
||||
let watch = new Watch();
|
||||
let lock = new WakeLock();
|
||||
|
||||
Views();
|
||||
|
||||
FileController();
|
||||
WorkoutController();
|
||||
DeviceController({controllable: flux,
|
||||
powerMeter: pm,
|
||||
watch: watch,
|
||||
hrb: hrb,
|
||||
antHrm: antHrm,
|
||||
antFec: antFec,
|
||||
});
|
||||
|
||||
xf.dispatch('app:start');
|
||||
|
||||
Vibrate({turnOn: true});
|
||||
// DataMock({hr: true, pwr: true});
|
||||
};
|
||||
|
||||
// startServiceWroker();
|
||||
|
||||
start();
|
||||
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
"transform": {
|
||||
"^.+\\.js$": "babel-jest"
|
||||
}
|
||||
};
|
||||
13728
package-lock.json
generated
22
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "flux",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "parcel index.html",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {}
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.13.12",
|
||||
"jest": "^26.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.13.13",
|
||||
"@babel/node": "^7.13.13"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/ant/ant.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { first, last, exists, prn } from '../functions.js';
|
||||
import { Serial } from 'serial.js';
|
||||
|
||||
// private
|
||||
const _type = 'ant';
|
||||
|
||||
const _ = {};
|
||||
|
||||
// public
|
||||
class ANT {
|
||||
get type() { return _type; }
|
||||
}
|
||||
|
||||
export { ANT, _ };
|
||||
101
src/ant/serial.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { first, last, exists, prn } from '../functions.js';
|
||||
|
||||
const values = {
|
||||
Dynastream_Id: 4047,
|
||||
ANT_USB_2_Stick_Id: 1008,
|
||||
ANT_USB_m_Stick_Id: 1009,
|
||||
Baud_Rate: 115200,
|
||||
};
|
||||
|
||||
function filter() {
|
||||
return [{usbVendorId: values.Dynastream_Id}];
|
||||
}
|
||||
|
||||
async function isSupported() {
|
||||
return 'serial' in navigator;
|
||||
}
|
||||
|
||||
async function requestPort(filters = filter()) {
|
||||
const port = await navigator.serial.requestPort({ filters: filters });
|
||||
return port;
|
||||
}
|
||||
|
||||
async function getPorts() {
|
||||
const ports = await navigator.serial.getPorts();
|
||||
return ports;
|
||||
}
|
||||
|
||||
async function open(port) {
|
||||
await port.open({ baudRate: values.Baud_Rate });
|
||||
return port;
|
||||
}
|
||||
|
||||
|
||||
class Serial {}
|
||||
|
||||
|
||||
|
||||
export { Serial, values, filter, requestPort, getPorts, open };
|
||||
|
||||
|
||||
// class Serial {
|
||||
|
||||
// async read() {
|
||||
// const self = this;
|
||||
// while (self.port.readable && self.keepReading) {
|
||||
// self.reader = self.port.readable.pipeThrough(new TransformStream(new MessageTransformer())).getReader();
|
||||
// try {
|
||||
// while (true) {
|
||||
// const { value, done } = await self.reader.read();
|
||||
// if (done) { break; }
|
||||
// self.onData(value);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error(`ant+ usb reader error: ${error}`);
|
||||
// } finally {
|
||||
// self.reader.releaseLock();
|
||||
// }
|
||||
// }
|
||||
// self.writer.releaseLock();
|
||||
// await self.port.close();
|
||||
// }
|
||||
// }
|
||||
|
||||
class MessageTransformer {
|
||||
constructor() {
|
||||
this.container = [];
|
||||
}
|
||||
transform(chunk, controller) {
|
||||
const self = this;
|
||||
self.container.push(Array.from(chunk));
|
||||
self.container = self.container.flat();
|
||||
let msgs = splitAt(self.container, 164);
|
||||
self.container = msgs.pop();
|
||||
msgs.forEach(msg => controller.enqueue(msg));
|
||||
}
|
||||
flush(controller) {
|
||||
const self = this;
|
||||
controller.enqueue(self.container);
|
||||
}
|
||||
}
|
||||
|
||||
// const self = this;
|
||||
// const port = await navigator.serial.requestPort({filters: filter});
|
||||
// return port;
|
||||
// self.writer = self.port.writable.getWriter();
|
||||
|
||||
|
||||
// function isAntStick(portInfo) {
|
||||
// return portInfo.usbVendorId === Dynastream_Id;
|
||||
// }
|
||||
|
||||
// function includesAntStick(ports) {
|
||||
// if(empty(ports)) return false;
|
||||
// const antSticks = ports.filter(p => isAntStick(p.getInfo()));
|
||||
// if(empty(antSticks)) return false;
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// function getAntStick(ports) {
|
||||
// return first(ports.filter(p => isAntStick(p.getInfo())));
|
||||
// }
|
||||
56
src/ble/dis/dis.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { uuids } from '../uuids.js';
|
||||
import { dataviewToString } from '../../functions.js';
|
||||
|
||||
class DeviceInformationService {
|
||||
uuid = uuids.deviceInformation;
|
||||
characteristics = {};
|
||||
constructor(args) {
|
||||
this.ble = args.ble;
|
||||
this.device = args.device;
|
||||
this.server = args.server;
|
||||
this.deviceServices = args.services;
|
||||
this.onInfo = args.onInfo || ((x) => x);
|
||||
this._info = this.defaultInfo();
|
||||
}
|
||||
get info() { return this._info; }
|
||||
set info(x) { return this._info = x; }
|
||||
defaultManufecturerNameString() { return 'Unknown'; }
|
||||
defaultModelNumberString() { return ''; }
|
||||
defaultFirmwareRevisionString() { return ''; }
|
||||
defaultInfo() {
|
||||
const self = this;
|
||||
return { manufacturer: self.defaultManufecturerNameString(),
|
||||
model: self.defaultModelNumberString(),
|
||||
firmware: self.defaultFirmwareRevisionString() };
|
||||
}
|
||||
async init() {
|
||||
const self = this;
|
||||
self.service = await self.ble.getService(self.server, self.uuid);
|
||||
self.characteristics = await self.getCharacteristics(self.service);
|
||||
self.info = await self.readInfo(self.characteristics);
|
||||
}
|
||||
async getCharacteristics(service) {
|
||||
const self = this;
|
||||
const manufacturerNameString = await self.ble.getCharacteristic(service, uuids.manufacturerNameString);
|
||||
const modelNumberString = await self.ble.getCharacteristic(service, uuids.modelNumberString);
|
||||
const firmwareRevisionString = await self.ble.getCharacteristic(service, uuids.firmwareRevisionString);
|
||||
return { manufacturerNameString, modelNumberString, firmwareRevisionString };
|
||||
}
|
||||
async readInfo(characteristics) {
|
||||
const self = this;
|
||||
const manufacturerDataview = (await self.ble.readCharacteristic(characteristics.manufacturerNameString)).value;
|
||||
const modelDataview = (await self.ble.readCharacteristic(characteristics.modelNumberString)).value;
|
||||
const firmwareDataview = (await self.ble.readCharacteristic(characteristics.firmwareRevisionString)).value;
|
||||
|
||||
const manufacturer = dataviewToString(manufacturerDataview) || self.defaultManufecturerNameString();
|
||||
const model = dataviewToString(modelDataview) || self.defaultModelNumberString();
|
||||
const firmware = dataviewToString(firmwareDataview) || self.defaultFirmwareRevisionString();
|
||||
|
||||
const info = { manufacturer, model, firmware };
|
||||
|
||||
self.onInfo(info);
|
||||
return info;
|
||||
}
|
||||
};
|
||||
|
||||
export { DeviceInformationService };
|
||||
@@ -45,7 +45,7 @@ const controlPointOperations = {
|
||||
msg: 'Spin Down Control'},
|
||||
};
|
||||
|
||||
function dataviewToControlPointResponse(dataview) {
|
||||
function controlPointResponseDecoder(dataview) {
|
||||
|
||||
let res = {
|
||||
responseCode: dataview.getUint8(0, true),
|
||||
@@ -100,16 +100,26 @@ function resistanceTarget(resistance) {
|
||||
let buffer = new ArrayBuffer(3);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, OpCode, true);
|
||||
// view.setUint8(1, parseInt(resistance), true); // by Spec
|
||||
// view.setUint8(1, resistance, true); // by Spec
|
||||
view.setInt16(1, resistance, true); // works with Tacx
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function requestControl() {
|
||||
const OpCode = 0x00;
|
||||
let buffer = new ArrayBuffer(1);
|
||||
let view = new DataView(buffer);
|
||||
view.setUint8(0, OpCode, true);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
export {
|
||||
powerTarget,
|
||||
resistanceTarget,
|
||||
slopeTarget,
|
||||
simulationParameters,
|
||||
dataviewToControlPointResponse
|
||||
requestControl,
|
||||
controlPointResponseDecoder
|
||||
};
|
||||
@@ -40,7 +40,7 @@ const target = {
|
||||
cadence: (flags) => nthBitToBool(flags, 16)
|
||||
};
|
||||
|
||||
function dataviewToFitnessMachineFeature(dataview) {
|
||||
function fitnessMachineFeatureDecoder(dataview) {
|
||||
const featureFlags = dataview.getUint32(0, true); // 0-31 flags
|
||||
const targetFlags = dataview.getUint32(4, true); // 0-31 flags
|
||||
|
||||
@@ -63,4 +63,4 @@ function dataviewToFitnessMachineFeature(dataview) {
|
||||
return { readings, targets };
|
||||
}
|
||||
|
||||
export { dataviewToFitnessMachineFeature };
|
||||
export { fitnessMachineFeatureDecoder };
|
||||
@@ -16,11 +16,11 @@ const fitnessMachineStatusCodes = {
|
||||
'0xFF': {param: '', msg: 'Control Permission Lost'},
|
||||
};
|
||||
|
||||
function dataviewToFitnessMachineStatus(dataview) {
|
||||
function fitnessMachineStatusDecoder(dataview) {
|
||||
let status = dataview.getUint8(0, dataview, true);
|
||||
let msg = fitnessMachineStatusCodes[hex(status)].msg;
|
||||
|
||||
return {status, msg};
|
||||
}
|
||||
|
||||
export { dataviewToFitnessMachineStatus };
|
||||
export { fitnessMachineStatusDecoder };
|
||||
89
src/ble/ftms/ftms.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { uuids } from '../uuids.js';
|
||||
import { indoorBikeDataDecoder } from './indoor-bike-data.js';
|
||||
|
||||
import { powerTarget,
|
||||
resistanceTarget,
|
||||
slopeTarget,
|
||||
simulationParameters,
|
||||
requestControl,
|
||||
controlPointResponseDecoder } from './control-point.js';
|
||||
|
||||
import { fitnessMachineStatusDecoder } from './fitness-machine-status.js';
|
||||
|
||||
import { fitnessMachineFeatureDecoder } from './fitness-machine-feature.js';
|
||||
|
||||
import { supportedPowerRange,
|
||||
supportedResistanceLevelRange } from './supported.js';
|
||||
|
||||
function eventToValue(decoder, cb) {
|
||||
return function (e) {
|
||||
return cb(decoder(e.target.value));
|
||||
};
|
||||
}
|
||||
|
||||
class FitnessMachineService {
|
||||
uuid = uuids.fitnessMachine;
|
||||
characteristics = {};
|
||||
constructor(args) {
|
||||
this.ble = args.ble;
|
||||
this.device = args.device;
|
||||
this.server = args.server;
|
||||
this.deviceServices = args.services;
|
||||
this.onStatus = args.onStatus || ((x) => x);
|
||||
this.onControl = args.onControl || ((x) => x);
|
||||
this.onData = args.onData || ((x) => x);
|
||||
}
|
||||
async init() {
|
||||
const self = this;
|
||||
self.service = await self.ble.getService(self.server, self.uuid);
|
||||
self.characteristics = await self.getCharacteristics(self.service);
|
||||
|
||||
self.ble.sub(self.characteristics.fitnessMachineStatus,
|
||||
eventToValue(fitnessMachineStatusDecoder, self.onStatus));
|
||||
|
||||
self.ble.sub(self.characteristics.indoorBikeData,
|
||||
eventToValue(indoorBikeDataDecoder, self.onData));
|
||||
|
||||
await self.ble.sub(self.characteristics.fitnessMachineControlPoint,
|
||||
eventToValue(controlPointResponseDecoder, self.onControl));
|
||||
|
||||
await self.requestControl();
|
||||
}
|
||||
async getCharacteristics(service) {
|
||||
const self = this;
|
||||
const fitnessMachineFeature = await self.ble.getCharacteristic(service, uuids.fitnessMachineFeature);
|
||||
const supportedPowerRange = await self.ble.getCharacteristic(service, uuids.supportedPowerRange);
|
||||
const supportedResistanceLevelRange = await self.ble.getCharacteristic(service, uuids.supportedResistanceLevelRange);
|
||||
const fitnessMachineStatus = await self.ble.getCharacteristic(service, uuids.fitnessMachineStatus);
|
||||
const fitnessMachineControlPoint = await self.ble.getCharacteristic(service, uuids.fitnessMachineControlPoint);
|
||||
const indoorBikeData = await self.ble.getCharacteristic(service, uuids.indoorBikeData);
|
||||
|
||||
return { fitnessMachineFeature,
|
||||
supportedPowerRange,
|
||||
supportedResistanceLevelRange,
|
||||
fitnessMachineStatus,
|
||||
fitnessMachineControlPoint,
|
||||
indoorBikeData };
|
||||
}
|
||||
async requestControl() {
|
||||
const self = this;
|
||||
return await self.ble.writeCharacteristic(self.characteristics.fitnessMachineControlPoint, requestControl().buffer);
|
||||
}
|
||||
async setTargetPower(value) {
|
||||
const buffer = powerTarget(value);
|
||||
const characteristic = self.characteristics.fitnessMachineControlPoint;
|
||||
self.ble.writeCharacteristic(characteristic, buffer);
|
||||
}
|
||||
async setTargetResistance(value) {
|
||||
const buffer = resistanceTarget(value);
|
||||
const characteristic = self.characteristics.fitnessMachineControlPoint;
|
||||
self.ble.writeCharacteristic(characteristic, buffer);
|
||||
}
|
||||
async setTargetSlope(value) {
|
||||
const buffer = slopeTarget(value);
|
||||
const characteristic = self.characteristics.fitnessMachineControlPoint;
|
||||
self.ble.writeCharacteristic(characteristic, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
export { FitnessMachineService };
|
||||
@@ -88,7 +88,7 @@ function getCadence(dataview) {
|
||||
return (cadence * fields.InstantaneousCandence.resolution);
|
||||
}
|
||||
function getDistance(dataview) {
|
||||
const flags = dataview.getUint16(0, true);
|
||||
const flags = dataview.getUint16(0, true);
|
||||
const distance = dataview.getUint16(distanceIndex(flags), true);
|
||||
return (distance * fields.TotalDistance.resolution);
|
||||
}
|
||||
@@ -108,9 +108,9 @@ function getPower(dataview) {
|
||||
// flags 68, 0b01000100
|
||||
// flags 100, 0b01100100
|
||||
|
||||
function dataviewToIndoorBikeData(dataview) {
|
||||
function indoorBikeDataDecoder(dataview) {
|
||||
const flags = dataview.getUint16(0, true);
|
||||
let data = {};
|
||||
let data = {};
|
||||
|
||||
if(speedPresent(flags)) {
|
||||
data['speed'] = getSpeed(dataview);
|
||||
@@ -128,4 +128,4 @@ function dataviewToIndoorBikeData(dataview) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export { dataviewToIndoorBikeData };
|
||||
export { indoorBikeDataDecoder };
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
function dataviewToSupportedPowerRange(dataview) {
|
||||
function supportedPowerRange(dataview) {
|
||||
// (0x) 00-00-20-03-01-00
|
||||
let min = dataview.getUint16(0, dataview, true);
|
||||
let max = dataview.getUint16(2, dataview, true);
|
||||
@@ -8,7 +8,7 @@ function dataviewToSupportedPowerRange(dataview) {
|
||||
return { min, max, inc };
|
||||
}
|
||||
|
||||
function dataviewToSupportedResistanceLevelRange(dataview) {
|
||||
function supportedResistanceLevelRange(dataview) {
|
||||
// (0x) 00-00-E8-03-01-00
|
||||
let min = dataview.getUint16(0, dataview, true);
|
||||
let max = dataview.getUint16(2, dataview, true);
|
||||
@@ -18,6 +18,6 @@ function dataviewToSupportedResistanceLevelRange(dataview) {
|
||||
}
|
||||
|
||||
export {
|
||||
dataviewToSupportedPowerRange,
|
||||
dataviewToSupportedResistanceLevelRange
|
||||
supportedPowerRange,
|
||||
supportedResistanceLevelRange
|
||||
};
|
||||
@@ -16,7 +16,7 @@ function hrIndex(flags) {
|
||||
return 1;
|
||||
};
|
||||
|
||||
function dataviewToHeartRateMeasurement(dataview) {
|
||||
function heartRateMeasurementDecoder(dataview) {
|
||||
const flags = dataview.getUint8(0, true);
|
||||
|
||||
let hr;
|
||||
@@ -29,8 +29,4 @@ function dataviewToHeartRateMeasurement(dataview) {
|
||||
return { hr };
|
||||
}
|
||||
|
||||
let hrs = {
|
||||
dataviewToHeartRateMeasurement
|
||||
};
|
||||
|
||||
export { hrs };
|
||||
export { heartRateMeasurementDecoder };
|
||||
29
src/ble/hrs/hrs.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { uuids } from '../uuids.js';
|
||||
import { heartRateMeasurementDecoder } from './heartRateMeasurement.js';
|
||||
|
||||
function eventToValue(decoder, cb) {
|
||||
return function (e) {
|
||||
return cb(decoder(e.target.value));
|
||||
};
|
||||
}
|
||||
|
||||
class HeartRateService {
|
||||
uuid = uuids.heartRate;
|
||||
constructor(args) {
|
||||
this.ble = args.ble;
|
||||
this.device = args.device;
|
||||
this.server = args.server;
|
||||
this.deviceServices = args.services;
|
||||
this.onHeartRate = args.onHeartRate || ((x) => x);
|
||||
}
|
||||
async init() {
|
||||
const self = this;
|
||||
const heartRate = await self.ble.getService(self.server, self.uuid);
|
||||
const heartRateMeasurement = await self.ble.getCharacteristic(heartRate, uuids.heartRateMeasurement);
|
||||
|
||||
self.ble.sub(heartRateMeasurement,
|
||||
eventToValue(heartRateMeasurementDecoder, self.onHeartRate));
|
||||
}
|
||||
}
|
||||
|
||||
export { HeartRateService };
|
||||
44
src/ble/uuids.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const services = {
|
||||
gap: '00001800-0000-1000-8000-00805f9b34fb',
|
||||
fitnessMachine: '00001826-0000-1000-8000-00805f9b34fb',
|
||||
cyclingPower: '00001818-0000-1000-8000-00805f9b34fb',
|
||||
heartRate: '0000180d-0000-1000-8000-00805f9b34fb',
|
||||
batteryService: '0000180f-0000-1000-8000-00805f9b34fb',
|
||||
deviceInformation: '0000180a-0000-1000-8000-00805f9b34fb',
|
||||
fec: '6e40fec1-b5a3-f393-e0a9-e50e24dcca9e',
|
||||
};
|
||||
|
||||
const characteristics = {
|
||||
// Fitness Machine
|
||||
indoorBikeData: '00002ad2-0000-1000-8000-00805f9b34fb',
|
||||
fitnessMachineControlPoint: '00002ad9-0000-1000-8000-00805f9b34fb',
|
||||
fitnessMachineFeature: '00002acc-0000-1000-8000-00805f9b34fb',
|
||||
supportedResistanceLevelRange: '00002ad6-0000-1000-8000-00805f9b34fb',
|
||||
supportedPowerRange: '00002ad8-0000-1000-8000-00805f9b34fb',
|
||||
fitnessMachineStatus: '00002ada-0000-1000-8000-00805f9b34fb',
|
||||
|
||||
// Cycling Power
|
||||
cyclingPowerMeasurement: '00002a63-0000-1000-8000-00805f9b34fb',
|
||||
cyclingPowerFeature: '00002a65-0000-1000-8000-00805f9b34fb',
|
||||
cyclingPowerControlPoint: '00002a66-0000-1000-8000-00805f9b34fb',
|
||||
sensorLocation: '00002a5A-0000-1000-8000-00805f9b34fb',
|
||||
|
||||
// Heart Rate
|
||||
heartRateMeasurement: '00002a37-0000-1000-8000-00805f9b34fb',
|
||||
|
||||
// Battery
|
||||
batteryLevel: '00002a19-0000-1000-8000-00805f9b34fb',
|
||||
|
||||
// Device Information
|
||||
manufacturerNameString: '00002a29-0000-1000-8000-00805f9b34fb',
|
||||
modelNumberString: '00002a24-0000-1000-8000-00805f9b34fb',
|
||||
firmwareRevisionString: '00002a26-0000-1000-8000-00805f9b34fb',
|
||||
|
||||
// FEC over BLE
|
||||
fec2: '6e40fec2-b5a3-f393-e0a9-e50e24dcca9e',
|
||||
fec3: '6e40fec3-b5a3-f393-e0a9-e50e24dcca9e',
|
||||
};
|
||||
|
||||
const uuids = { ...services, ...characteristics };
|
||||
|
||||
export { uuids };
|
||||
169
src/ble/web-ble.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import { exists, first, last, empty, filterIn, findByValue } from '../functions.js';
|
||||
import { uuids } from './uuids.js';
|
||||
|
||||
////
|
||||
// private
|
||||
////
|
||||
const _type = 'web-ble';
|
||||
|
||||
const _hrm = {filters: [{services: [uuids.heartRate]}],
|
||||
optionalServices: [uuids.deviceInformation]};
|
||||
|
||||
const _controllable = {filters: [{services: [uuids.fitnessMachine]},
|
||||
{services: [uuids.fec]}],
|
||||
optionalServices: [uuids.deviceInformation]};
|
||||
|
||||
const _power = {filters: [{services: [uuids.cyclingPower]}],
|
||||
optionalServices: [uuids.deviceInformation]};
|
||||
|
||||
const _all = {acceptAllDevices: true};
|
||||
|
||||
function filterDevice(devices, id) {
|
||||
return filterIn(devices, id);
|
||||
}
|
||||
|
||||
function includesDevice(devices, id) {
|
||||
return devices.map(device => device.id).includes(device => device.id === id);
|
||||
}
|
||||
|
||||
const _ = { filterDevice, includesDevice };
|
||||
|
||||
////
|
||||
// public
|
||||
////
|
||||
|
||||
class WebBLE {
|
||||
requestFilters = { hrm: _hrm, controllable: _controllable, power: _power , all: _all };
|
||||
constructor(args) {}
|
||||
get type() { return _type; }
|
||||
async connect(filter) {
|
||||
const self = this;
|
||||
const device = await self.request(filter);
|
||||
const server = await self.gattConnect(device);
|
||||
const services = await self.getPrimaryServices(server);
|
||||
return { device, server, services };
|
||||
}
|
||||
async disconnect(device) {
|
||||
const self = this;
|
||||
await self.gattDisconnect(device);
|
||||
return device;
|
||||
}
|
||||
isConnected(device) {
|
||||
return device.server.connected;
|
||||
}
|
||||
async sub(characteristic, handler) {
|
||||
const self = this;
|
||||
await self.startNotifications(characteristic, handler);
|
||||
return characteristic;
|
||||
}
|
||||
async request(filter) {
|
||||
return await navigator.bluetooth.requestDevice(filter);
|
||||
}
|
||||
async getDevices() {
|
||||
const self = this;
|
||||
return await navigator.bluetooth.getDevices();
|
||||
}
|
||||
async isPaired(device) {
|
||||
const self = this;
|
||||
const devices = await self.getDevices();
|
||||
return includesDevice(devices, device.id);
|
||||
}
|
||||
async getPairedDevice(deviceId) {
|
||||
const self = this;
|
||||
const devices = await self.getDevices();
|
||||
return filterDevice(devices, deviceId);
|
||||
}
|
||||
async gattConnect(device) {
|
||||
const self = this;
|
||||
const server = await device.gatt.connect();
|
||||
return server;
|
||||
}
|
||||
async gattDisconnect(device) {
|
||||
const self = this;
|
||||
return await device.gatt.disconnect();
|
||||
}
|
||||
async getPrimaryServices(server) {
|
||||
const self = this;
|
||||
const services = await server.getPrimaryServices();
|
||||
return services;
|
||||
}
|
||||
async getService(server, uuid) {
|
||||
const self = this;
|
||||
const service = await server.getPrimaryService(uuid);
|
||||
return service;
|
||||
}
|
||||
async getCharacteristic(service, uuid) {
|
||||
const self = this;
|
||||
const characteristic = await service.getCharacteristic(uuid);
|
||||
return characteristic;
|
||||
}
|
||||
async getCharacteristics(service, uuids) {
|
||||
const self = this;
|
||||
let characteristics = {};
|
||||
// for await (let uuid of uuids) {
|
||||
// const characteristic = await self.getCharacteristic(service, uuid);
|
||||
// characteristics.push(characteristic);
|
||||
// }
|
||||
return characteristics;
|
||||
}
|
||||
async getDescriptors(characteristic) {
|
||||
const self = this;
|
||||
const descriptors = await characteristic.getDescriptors();
|
||||
return descriptors;
|
||||
}
|
||||
async getDescriptor(characteristic, uuid) {
|
||||
const self = this;
|
||||
const descriptor = await characteristic.getDescriptor(uuid);
|
||||
return descriptor;
|
||||
}
|
||||
async startNotifications(characteristic, handler) {
|
||||
const self = this;
|
||||
await characteristic.startNotifications();
|
||||
characteristic.addEventListener('characteristicvaluechanged', handler);
|
||||
console.log(`Notifications started on ${findByValue(uuids, characteristic.uuid)}: ${characteristic.uuid}.`);
|
||||
return characteristic;
|
||||
}
|
||||
async stopNotifications(characteristic, handler) {
|
||||
let self = this;
|
||||
await characteristic.stopNotifications();
|
||||
characteristic.removeEventListener('characteristicvaluechanged', handler);
|
||||
console.log(`Notifications stopped on ${findByValue(uuids, characteristic.uuid)}: ${characteristic.uuid}.`);
|
||||
return characteristic;
|
||||
}
|
||||
async writeCharacteristic(characteristic, value) {
|
||||
const self = this;
|
||||
let res = undefined;
|
||||
console.log(value);
|
||||
try{
|
||||
if('writeValueWithResponse' in characteristic) {
|
||||
res = await characteristic.writeValueWithResponse(value);
|
||||
} else {
|
||||
res = await characteristic.writeValue(value);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(`characteristic.writeValue:`, e);
|
||||
}
|
||||
return { res, characteristic };
|
||||
}
|
||||
async readCharacteristic(characteristic) {
|
||||
const self = this;
|
||||
let value = new DataView(new Uint8Array([0]).buffer); // ????
|
||||
try{
|
||||
value = await characteristic.readValue();
|
||||
} catch(e) {
|
||||
console.error(`characteristic.readValue: ${e}`);
|
||||
}
|
||||
return { value, characteristic };
|
||||
}
|
||||
isSupported() {
|
||||
if(!exists(navigator)) throw new Error(`Trying to use web-bluetooth in non-browser env!`);
|
||||
return 'bluetooth' in navigator;
|
||||
}
|
||||
isSwitchedOn() {
|
||||
return navigator.bluetooth.getAvailability();
|
||||
}
|
||||
}
|
||||
|
||||
const ble = new WebBLE();
|
||||
|
||||
export { ble, _ };
|
||||
144
src/controllers/controllers.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { xf, exists, empty, first, filterIn, prn } from '../functions.js';
|
||||
// import { Device } from '../device.js';
|
||||
|
||||
import { uuids } from '../ble/uuids.js';
|
||||
import { ble } from '../ble/web-ble.js';
|
||||
|
||||
// handlers
|
||||
import { HeartRateService } from '../ble/hrs/hrs.js';
|
||||
import { DeviceInformationService } from '../ble/dis/dis.js';
|
||||
import { FitnessMachineService } from '../ble/ftms/ftms.js';
|
||||
|
||||
function onHeartRate(value) {
|
||||
if(value.hr) xf.dispatch(`heartRate`, value.hr);
|
||||
}
|
||||
function onHrmInfo(value) {
|
||||
console.log(`Heart Rate Monitor Information: `, value);
|
||||
}
|
||||
function onIndoorBikeData(value) {
|
||||
console.log(`indoor bike data`, value);
|
||||
if(value.power) xf.dispatch(`power`, value.power);
|
||||
if(value.cadence) xf.dispatch(`cadence`, value.cadence);
|
||||
if(value.speed) xf.dispatch(`speed`, value.speed);
|
||||
}
|
||||
function onControllableInfo(value) {
|
||||
console.log(`Fitness Machine Information: `, value);
|
||||
}
|
||||
function onFitnessMachineStatus(value) {
|
||||
console.log(`Fitness Machine Status: `, value);
|
||||
}
|
||||
function onFitnessMachineControlPoint(value) {
|
||||
console.log(`Fitness Machine Control Point Response: `, value);
|
||||
}
|
||||
|
||||
class Device {
|
||||
constructor(args) {
|
||||
if(!exists(args)) args = {};
|
||||
this.id = args.id || this.defaultId();
|
||||
this.filter = args.filter || this.defaultFilter();
|
||||
this.init();
|
||||
}
|
||||
defaultFilter() { return ble.requestFilters.all; }
|
||||
defaultId() { return 'ble-device'; }
|
||||
init() {
|
||||
const self = this;
|
||||
|
||||
xf.sub(`ui:${self.id}:switch`, async () => {
|
||||
if(self.isConnected()) {
|
||||
self.disconnect();
|
||||
} else {
|
||||
self.connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
postInit() {}
|
||||
isConnected() {
|
||||
const self = this;
|
||||
if(exists(self.device)) return ble.isConnected(self.device);
|
||||
return false;
|
||||
}
|
||||
async connect() {
|
||||
const self = this;
|
||||
xf.dispatch(`${self.id}:connecting`);
|
||||
try {
|
||||
self.device = await ble.connect(self.filter);
|
||||
await self.initServices(self.device);
|
||||
self.postInit();
|
||||
xf.dispatch(`${self.id}:connected`);
|
||||
} catch(err) {
|
||||
xf.dispatch(`${self.id}:disconnected`);
|
||||
console.error(`Could not request ${self.id}: `, err);
|
||||
}
|
||||
}
|
||||
disconnect() {
|
||||
const self = this;
|
||||
xf.dispatch(`${self.id}:disconnected`);
|
||||
return ble.disconnect(self.device.device);
|
||||
}
|
||||
async initServices(device) { return {}; }
|
||||
}
|
||||
|
||||
class Controllable extends Device {
|
||||
defaultId() { return `controllable`; }
|
||||
defaultFilter() { return ble.requestFilters.controllable; }
|
||||
postInit() {
|
||||
const self = this;
|
||||
|
||||
let mode = 'erg';
|
||||
xf.sub(`db:mode`, value => mode = mode);
|
||||
|
||||
xf.sub('db:powerTarget', power => {
|
||||
if(mode === 'erg') {
|
||||
if(self.device.connected) self.services.ftms.setPowerTarget(power);
|
||||
}
|
||||
});
|
||||
|
||||
xf.sub('db:resistanceTarget', resistance => {
|
||||
if(self.device.connected) self.services.ftms.setResistanceTarget(resistance);
|
||||
});
|
||||
|
||||
xf.sub('db:slopeTarget', slope => {
|
||||
if(self.device.connected) self.services.ftms.setSlopeTarget(slope);
|
||||
});
|
||||
}
|
||||
async initServices(device) {
|
||||
const dis = new DeviceInformationService({ble: ble, onInfo: onControllableInfo, ...device});
|
||||
await dis.init();
|
||||
|
||||
const ftms = new FitnessMachineService({ble: ble,
|
||||
onStatus: onFitnessMachineStatus,
|
||||
onData: onIndoorBikeData,
|
||||
onControl: onFitnessMachineControlPoint,
|
||||
...device});
|
||||
await ftms.init();
|
||||
return { dis, ftms };
|
||||
}
|
||||
}
|
||||
|
||||
class Hrm extends Device {
|
||||
defaultId() { return `hrm`; }
|
||||
defaultFilter() { return ble.requestFilters.hrm; }
|
||||
postInit() {
|
||||
const self = this;
|
||||
}
|
||||
async initServices(device) {
|
||||
const hrs = new HeartRateService({ble: ble, onHeartRate: onHeartRate, ...device});
|
||||
await hrs.init();
|
||||
|
||||
const dis = new DeviceInformationService({ble: ble, onInfo: onHrmInfo, ...device});
|
||||
await dis.init();
|
||||
|
||||
return { hrs, dis };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function start() {
|
||||
console.log(`start controllers`);
|
||||
|
||||
const controllable = new Controllable();
|
||||
const hrm = new Hrm();
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -465,9 +465,10 @@ body.dark-theme {
|
||||
|
||||
|
||||
:root {
|
||||
/* --ss-font-family: sans-serif; */
|
||||
/* --ss-font-family: 'Source Sans Pro', sans-serif; */
|
||||
--ssb-font-family: 'Oswald', sans-serif;
|
||||
/* --ssb-font-family: sans-serif; */
|
||||
/* --ssr-font-family: 'Source Sans Pro', sans-serif; */
|
||||
/* --ssb-font-family: 'Oswald', sans-serif; */
|
||||
--ssb-font-family: 'Roboto', sans-serif;
|
||||
--ssr-font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
.wrapper {
|
||||
@@ -579,7 +580,7 @@ body.dark-theme {
|
||||
|
||||
/* Graphs */
|
||||
.graph {
|
||||
--graph-total-height: 100px;
|
||||
--graph-total-height: 120px;
|
||||
--graph-height: 100px;
|
||||
|
||||
position: relative;
|
||||
@@ -651,7 +652,7 @@ body.dark-theme {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
height: var(--graph-height);
|
||||
height: 110px;
|
||||
padding: 0;
|
||||
}
|
||||
#progress {
|
||||
@@ -662,8 +663,7 @@ body.dark-theme {
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
width: 0px;
|
||||
|
||||
height: var(--graph-height);
|
||||
height: 102px;
|
||||
}
|
||||
#progress-active {
|
||||
display: flex;
|
||||
@@ -673,8 +673,7 @@ body.dark-theme {
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
width: 0px;
|
||||
|
||||
height: var(--graph-height);
|
||||
height: 102px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
62
src/db.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { xf, exists, equals, prn } from '../functions.js';
|
||||
import { models } from './models/models.js';
|
||||
|
||||
let db = {
|
||||
power: models.power.default(),
|
||||
heartRate: models.heartRate.default(),
|
||||
|
||||
powerTarget: models.powerTarget.default(),
|
||||
};
|
||||
|
||||
xf.create(db);
|
||||
|
||||
xf.reg(models.heartRate.prop, (heartRate, db) => {
|
||||
if(models.heartRate.validate(heartRate)) db.heartRate = heartRate;
|
||||
models.heartRate.onInvlid();
|
||||
});
|
||||
|
||||
xf.reg(models.power.prop, (power, db) => {
|
||||
if(models.power.validate(power)) db.power = power;
|
||||
models.power.onInvlid();
|
||||
});
|
||||
|
||||
xf.reg(models.powerTarget.prop, (powerTarget, db) => {
|
||||
if(models.powerTarget.validate(powerTarget)) db.powerTarget = powerTarget;
|
||||
db.powerTarget = models.powerTarget.toPowerTarget(powerTarget);
|
||||
});
|
||||
|
||||
xf.sub('app:start', _ => {
|
||||
for(let model of models) {
|
||||
model.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// class ViewValue {
|
||||
// constructor(args) {
|
||||
// this.render = args.render || this.defaultRender();
|
||||
// this.init();
|
||||
// }
|
||||
// init() {
|
||||
// const self = this;
|
||||
// xf.sub(`${self.prop}`, self.onUpdate.bind(self));
|
||||
// }
|
||||
// onUpdate(value) {
|
||||
// const self = this;
|
||||
// if(equals(self.prev, value)) return;
|
||||
// self.render(value);
|
||||
// self.prev = value;
|
||||
// }
|
||||
// defaultValue() { return ''; }
|
||||
// defaultRender(value) {
|
||||
// const self = this;
|
||||
// console.log(`:${self.prop} ${value}`);
|
||||
// }
|
||||
// }
|
||||
14
src/device.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { exists } from './functions.js';
|
||||
|
||||
class Device {
|
||||
constructor(args) {
|
||||
if(!exists(args)) args = {};
|
||||
this.protocol = args.protocol;
|
||||
}
|
||||
connect() {
|
||||
const self = this;
|
||||
return this.protocol.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export { Device }
|
||||
|
Before Width: | Height: | Size: 198 B After Width: | Height: | Size: 198 B |
|
Before Width: | Height: | Size: 595 B After Width: | Height: | Size: 595 B |
|
Before Width: | Height: | Size: 574 B After Width: | Height: | Size: 574 B |
|
Before Width: | Height: | Size: 602 B After Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 221 B After Width: | Height: | Size: 221 B |
311
src/functions.js
Normal file
@@ -0,0 +1,311 @@
|
||||
|
||||
|
||||
|
||||
// Values
|
||||
function equals(a, b) {
|
||||
return Object.is(a, b);
|
||||
}
|
||||
|
||||
// Collections
|
||||
function isArray(x) {
|
||||
return Array.isArray(x);
|
||||
}
|
||||
|
||||
function isObject(x) {
|
||||
return equals(typeof x, 'object') && !(isArray(x));
|
||||
}
|
||||
|
||||
function isCollection(x) {
|
||||
return isArray(x) || isObject(x);
|
||||
}
|
||||
|
||||
function isString(x) {
|
||||
return equals(typeof x, 'string');
|
||||
}
|
||||
|
||||
function isNull(x) {
|
||||
return Object.is(x, null);
|
||||
}
|
||||
|
||||
function isUndefined(x) {
|
||||
return Object.is(x, undefined);
|
||||
}
|
||||
|
||||
function exists(x) {
|
||||
if(equals(x, null) || equals(x, undefined)) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function empty(x) {
|
||||
if(isNull(x)) throw new Error(`empty called with null: ${x}`);
|
||||
if(!isCollection(x) && !isString(x) && !isUndefined(x)) {
|
||||
throw new Error(`empty takes a collection: ${x}`);
|
||||
}
|
||||
if(isUndefined(x)) return true;
|
||||
if(isArray(x)) {
|
||||
if(equals(x.length, 0)) return true;
|
||||
}
|
||||
if(isObject(x)) {
|
||||
if(equals(Object.keys(x).length, 0)) return true;
|
||||
}
|
||||
if(isString(x)) {
|
||||
if(equals(x, "")) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function first(xs) {
|
||||
if(!isArray(xs) && !isString(xs) && !isUndefined(xs)) {
|
||||
throw new Error(`first takes ordered collection or a string: ${xs}`);
|
||||
}
|
||||
if(isUndefined(xs)) return undefined;
|
||||
if(empty(xs)) return undefined;
|
||||
return xs[0];
|
||||
}
|
||||
|
||||
function second(xs) {
|
||||
if(!isArray(xs) && !isString(xs) && !isUndefined(xs)) {
|
||||
throw new Error(`second takes ordered collection or a string: ${xs}`);
|
||||
}
|
||||
if(isUndefined(xs)) return undefined;
|
||||
if(empty(xs)) return undefined;
|
||||
if(xs.length < 2) return undefined;
|
||||
return xs[1];
|
||||
}
|
||||
|
||||
function third(xs) {
|
||||
if(!isArray(xs) && !isString(xs) && !isUndefined(xs)) {
|
||||
throw new Error(`third takes ordered collection or a string: ${xs}`);
|
||||
}
|
||||
if(isUndefined(xs)) return undefined;
|
||||
if(empty(xs)) return undefined;
|
||||
if(xs.length < 3) return undefined;
|
||||
return xs[2];
|
||||
}
|
||||
|
||||
function last(xs) {
|
||||
if(!isArray(xs) && !isString(xs) && !isUndefined(xs)) {
|
||||
throw new Error(`last takes ordered collection or a string: ${xs}`);
|
||||
}
|
||||
if(isUndefined(xs)) return undefined;
|
||||
if(empty(xs)) return undefined;
|
||||
return xs[xs.length - 1];
|
||||
}
|
||||
|
||||
function filterIn(coll, prop, value) {
|
||||
return first(coll.filter(x => x[prop] === value));
|
||||
}
|
||||
|
||||
function filterByValue(obj, value) {
|
||||
return Object.entries(obj).filter(kv => kv[1] === value);
|
||||
}
|
||||
|
||||
function findByValue(obj, value) {
|
||||
return first(first(filterByValue(obj, value)));
|
||||
}
|
||||
|
||||
function splitAt(xs, at) {
|
||||
if(!isArray(xs)) throw new Error(`splitAt takes an array: ${xs}`);
|
||||
let i = -1;
|
||||
return xs.reduce((acc, x) => {
|
||||
if((equals(x, at)) || (equals(acc.length, 0) && !equals(x, at))) {
|
||||
acc.push([x]); i++;
|
||||
} else {
|
||||
acc[i].push(x);
|
||||
}
|
||||
return acc;
|
||||
},[]);
|
||||
}
|
||||
|
||||
// Math
|
||||
function digits(n) {
|
||||
return Math.log(n) * Math.LOG10E + 1 | 0;
|
||||
}
|
||||
|
||||
function rand(min = 0, max = 10) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function gte(a, b) { return a >= b; }
|
||||
function lte(a, b) { return a <= b; }
|
||||
function gt(a, b) { return a > b; }
|
||||
function lt(a, b) { return a < b; }
|
||||
|
||||
function inRange(min, max, value, lb=gte, ub=lte) {
|
||||
return (lb(value, min) && ub(value, max));
|
||||
}
|
||||
|
||||
function fixInRange(min, max, value) {
|
||||
if(value >= max) {
|
||||
return max;
|
||||
} else if(value < min) {
|
||||
return min;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Util
|
||||
function prn(str) {
|
||||
console.log(str);
|
||||
}
|
||||
|
||||
// Bits
|
||||
function nthBit(field, bit) {
|
||||
return (field >> bit) & 1;
|
||||
};
|
||||
function bitToBool(bit) {
|
||||
return !!(bit);
|
||||
};
|
||||
function nthBitToBool(field, bit) {
|
||||
return bitToBool(nthBit(field, bit));
|
||||
}
|
||||
function getBitField(field, bit) {
|
||||
return (field >> bit) & 1;
|
||||
};
|
||||
function xor(view) {
|
||||
let cs = 0;
|
||||
for (let i=0; i < view.byteLength; i++) {
|
||||
cs ^= view.getUint8(i);
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
function hex(n) {
|
||||
return '0x'+(n).toString(16);
|
||||
}
|
||||
function dataviewToString(dataview) {
|
||||
let utf8decoder = new TextDecoder('utf-8');
|
||||
return utf8decoder.decode(dataview.buffer);
|
||||
}
|
||||
|
||||
// Async
|
||||
function delay(ms) {
|
||||
return new Promise(res => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
// Events
|
||||
function evt(name) {
|
||||
return function(value) {
|
||||
return new CustomEvent(name, {detail: {data: value}});
|
||||
};
|
||||
}
|
||||
|
||||
function evtSource(name) {
|
||||
return first(name.split(':'));
|
||||
}
|
||||
|
||||
function evtProp(name) {
|
||||
return second(name.split(':'));
|
||||
}
|
||||
|
||||
function unsub(name, handler) {
|
||||
}
|
||||
|
||||
function isStoreSource(name) {
|
||||
return equals(evtSource(name), 'db');
|
||||
}
|
||||
|
||||
// Store
|
||||
class Store {
|
||||
name = 'db';
|
||||
constructor(args) {
|
||||
this.data = args.data;
|
||||
}
|
||||
create(data) {
|
||||
const self = this;
|
||||
self.data = self.proxify(data);
|
||||
}
|
||||
proxify(data) {
|
||||
const self = this;
|
||||
let handler = {
|
||||
set: (target, key, value) => {
|
||||
target[key] = value;
|
||||
self.dispatch(`${self.name}:${key}`, target);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
return new Proxy(data, handler);
|
||||
}
|
||||
reg(name, handler) {
|
||||
const self = this;
|
||||
document.addEventListener(name, e => handler(e.detail.data, self.data));
|
||||
}
|
||||
sub(name, handler, el = false) {
|
||||
if(el) {
|
||||
el.addEventListener(name, e => {
|
||||
handler(e);
|
||||
}, true);
|
||||
} else {
|
||||
document.addEventListener(name, e => {
|
||||
if(isStoreSource(name)) {
|
||||
handler(e.detail.data[evtProp(name)]);
|
||||
} else {
|
||||
handler(e.detail.data);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
unsub(name, handler) {
|
||||
document.removeEventListener(name, handler, true);
|
||||
}
|
||||
dispatch(name, value) {
|
||||
document.dispatchEvent(evt(name)(value));
|
||||
}
|
||||
get(prop) {
|
||||
const self = this;
|
||||
return self.data[prop];
|
||||
}
|
||||
};
|
||||
|
||||
const xf = new Store({});
|
||||
|
||||
export {
|
||||
// values
|
||||
equals,
|
||||
exists,
|
||||
|
||||
// collections
|
||||
isNull,
|
||||
isUndefined,
|
||||
isArray,
|
||||
isObject,
|
||||
isString,
|
||||
isCollection,
|
||||
first,
|
||||
second,
|
||||
third,
|
||||
last,
|
||||
empty,
|
||||
filterIn,
|
||||
filterByValue,
|
||||
findByValue,
|
||||
splitAt,
|
||||
|
||||
// math
|
||||
digits,
|
||||
rand,
|
||||
gte,
|
||||
lte,
|
||||
gt,
|
||||
lt,
|
||||
inRange,
|
||||
fixInRange,
|
||||
|
||||
// utils
|
||||
prn,
|
||||
|
||||
// bits
|
||||
nthBit,
|
||||
bitToBool,
|
||||
nthBitToBool,
|
||||
getBitField,
|
||||
hex,
|
||||
dataviewToString,
|
||||
xor,
|
||||
|
||||
// async
|
||||
delay,
|
||||
|
||||
// events
|
||||
xf,
|
||||
};
|
||||
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 386 B |
|
Before Width: | Height: | Size: 623 B After Width: | Height: | Size: 623 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 426 B After Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 220 B |
|
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 224 B |
|
Before Width: | Height: | Size: 172 B After Width: | Height: | Size: 172 B |
|
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 158 B |
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 315 B |
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 362 B |
|
Before Width: | Height: | Size: 333 B After Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 516 B After Width: | Height: | Size: 516 B |
|
Before Width: | Height: | Size: 197 B After Width: | Height: | Size: 197 B |
|
Before Width: | Height: | Size: 249 B After Width: | Height: | Size: 249 B |
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 185 B After Width: | Height: | Size: 185 B |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 322 B |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 276 B |
|
Before Width: | Height: | Size: 299 B After Width: | Height: | Size: 299 B |
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 191 B After Width: | Height: | Size: 191 B |
|
Before Width: | Height: | Size: 210 B After Width: | Height: | Size: 210 B |
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 315 B |
|
Before Width: | Height: | Size: 183 B After Width: | Height: | Size: 183 B |
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 186 B After Width: | Height: | Size: 186 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 772 B After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 198 B After Width: | Height: | Size: 198 B |
|
Before Width: | Height: | Size: 236 B After Width: | Height: | Size: 236 B |
|
Before Width: | Height: | Size: 209 B After Width: | Height: | Size: 209 B |
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 279 B |
|
Before Width: | Height: | Size: 360 B After Width: | Height: | Size: 360 B |
|
Before Width: | Height: | Size: 185 B After Width: | Height: | Size: 185 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 285 B After Width: | Height: | Size: 285 B |
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 171 B |
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 376 B |
|
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B |
|
Before Width: | Height: | Size: 157 B After Width: | Height: | Size: 157 B |
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 230 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 221 B After Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 201 B After Width: | Height: | Size: 201 B |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |