ble layer mostly done

This commit is contained in:
dvmarinoff
2021-04-21 12:09:52 +03:00
parent b0b8ae7eb4
commit eac7b784c4
144 changed files with 15645 additions and 5812 deletions

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 }

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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
View File

@@ -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
View File

@@ -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 };

View File

@@ -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
};

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1,5 @@
module.exports = {
"transform": {
"^.+\\.js$": "babel-jest"
}
};

13728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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 };

View File

@@ -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
};

View File

@@ -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 };

View File

@@ -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
View 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 };

View File

@@ -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 };

View File

@@ -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
};

View File

@@ -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
View 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
View 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
View 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, _ };

View 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();

View File

@@ -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
View 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
View 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 }

View File

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 198 B

View File

Before

Width:  |  Height:  |  Size: 595 B

After

Width:  |  Height:  |  Size: 595 B

View File

Before

Width:  |  Height:  |  Size: 574 B

After

Width:  |  Height:  |  Size: 574 B

View File

Before

Width:  |  Height:  |  Size: 602 B

After

Width:  |  Height:  |  Size: 602 B

View File

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

311
src/functions.js Normal file
View 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,
};

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

View File

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 386 B

View File

Before

Width:  |  Height:  |  Size: 623 B

After

Width:  |  Height:  |  Size: 623 B

View File

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 407 B

View File

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

View File

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 273 B

View File

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 220 B

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 315 B

View File

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 362 B

View File

Before

Width:  |  Height:  |  Size: 333 B

After

Width:  |  Height:  |  Size: 333 B

View File

Before

Width:  |  Height:  |  Size: 516 B

After

Width:  |  Height:  |  Size: 516 B

View File

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 197 B

View File

Before

Width:  |  Height:  |  Size: 249 B

After

Width:  |  Height:  |  Size: 249 B

View File

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 240 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

View File

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 276 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 240 B

View File

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 191 B

View File

Before

Width:  |  Height:  |  Size: 210 B

After

Width:  |  Height:  |  Size: 210 B

View File

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 315 B

View File

Before

Width:  |  Height:  |  Size: 183 B

After

Width:  |  Height:  |  Size: 183 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 186 B

After

Width:  |  Height:  |  Size: 186 B

View File

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 772 B

View File

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 198 B

View File

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

View File

Before

Width:  |  Height:  |  Size: 209 B

After

Width:  |  Height:  |  Size: 209 B

View File

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 279 B

View File

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 360 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 285 B

View File

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 171 B

View File

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 376 B

View File

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View File

Before

Width:  |  Height:  |  Size: 157 B

After

Width:  |  Height:  |  Size: 157 B

View File

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

View File

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 230 B

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

View File

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

View File

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 201 B

View File

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

Some files were not shown because too many files have changed in this diff Show More