waterrower
Version:
Talk to your WaterRower!
314 lines (275 loc) • 11.2 kB
text/typescript
import { Observable, Subject } from 'rxjs/Rx';
import * as SerialPort from 'serialport';
import * as ayb from 'all-your-base';
import * as _ from 'lodash';
import * as events from 'events';
import datapoints from './datapoints';
import types from './types';
import * as fs from 'fs';
import * as readline from 'readline';
import * as moment from 'moment';
import * as path from 'path';
export class WaterRower extends events.EventEmitter {
private refreshRate: number = 200;
private baudRate: number = 19200;
private port: SerialPort;
private dataDirectory: string = 'lib/data';
private datapoints: string | string[];
private recordingSubscription;
// reads$ is all serial messages from the WR
// datapoints$ isonly the reads that are a report of a memory location's value
reads$ = new Subject<ReadValue>();
datapoints$: Observable<DataPoint>;
constructor(options: WaterRowerOptions = {}) {
super();
this.dataDirectory = options.dataDirectory || this.dataDirectory;
this.refreshRate = options.refreshRate || this.refreshRate;
this.baudRate = options.baudRate || this.baudRate;
this.datapoints = options.datapoints;
if (!options.portName) {
console.log('No port configured. Attempting to discover...');
this.discoverPort(name => {
if (name) {
console.log('Discovered a WaterRower on ' + name + '...');
options.portName = name;
this.setupSerialPort(options);
}
else
console.log('We didn\'t find any connected WaterRowers');
})
}
else {
console.log('Setting up serial port on ' + options.portName + '...');
this.setupSerialPort(options);
}
this.setupStreams();
process.on('SIGINT', () => {
this.close();
});
}
private discoverPort(callback) {
SerialPort.list((err, ports) => {
const p = _.find(ports, p => _.includes([
'Microchip Technology, Inc.', // standard
'Microchip Technology Inc.' // macOS specific?
], p.manufacturer));
if (p) callback(p.comName);
else callback();
});
}
private setupSerialPort(options) {
// setup the serial port
this.port = new SerialPort(options.portName, {
baudRate: options.baudRate || this.baudRate
});
// setup port events
this.port.on('open', () => {
console.log(`A connection to the WaterRower has been established on ${options.portName}`);
this.initialize();
if (options.refreshRate !== 0) setInterval(() => this.requestDataPoints(this.datapoints), this.refreshRate);
});
this.port.on('data', d => {
let type = _.find(types, t => t.pattern.test(d));
this.reads$.next({ time: Date.now(), type: (type ? type.type : 'other'), data: d })
});
this.port.on('closed', () => this.close);
this.port.on('disconnect', () => this.close)
this.port.on('error', err => {
this.emit('error', err);
this.close();
});
}
private setupStreams() {
// this is the important stream for reading memory locations from the rower
// IDS is a single, IDD is a double, and IDT is a triple byte memory location
this.datapoints$ = this.reads$
.filter(d => d.type === 'datapoint')
.map(d => {
let pattern = _.find(types, t => t.type == 'datapoint').pattern;
let m = pattern.exec(d.data);
return {
time: new Date(d.time),
name: _.find(datapoints, point => point.address == m[2]).name,
length: { 'S': 1, 'D': 2, 'T': 3 }[m[1]],
address: m[2],
value: m[3]
};
});
//emit the data event
this.datapoints$.subscribe(d => {
let datapoint = _.find(datapoints, d2 => d2.address == d.address);
datapoint.value = parseInt(d.value, datapoint.radix);
this.emit('data', datapoint);
})
// when the WR comes back with _WR_ then consider the WR initialized
this.reads$.filter(d => d.type == 'hardwaretype').subscribe(d => {
this.emit('initialized');
});
}
/// send a serial message
private send(value): void {
if (this.port) this.port.write(value + '\r\n');
}
/// initialize the connection
private initialize(): void {
console.log('Initializing port...');
this.send('USB');
}
private close(): void {
console.log('Closing WaterRower...');
this.emit('close');
this.reads$.complete();
if (this.port) {
this.port.close(err => console.log(err));
this.port = null;
}
process.exit();
}
/// reset console
reset(): void {
console.log('Resetting WaterRower...');
this.send('RESET'); //reset the waterrower
}
/// Issues a request for one, more, or all data points.
/// There is no return value. Data point values can be read very
/// shortly after the request is made
requestDataPoints(points?: string | string[]): void {
let req = (name: string): void => {
console.log('requesting ' + name);
let dataPoint = _.find(datapoints, d => d.name == name);
this.send(`IR${dataPoint.length}${dataPoint.address}`)
}
if (points) {
if (Array.isArray(points)) points.forEach(p => req(p));
else if (typeof points === 'string') req(points)
else throw ('requestDataPoint requires a string, an array of strings, or nothing at all');
}
else
datapoints.forEach(d => req(d.name));
}
readDataPoints(points?: string | string[]): any {
if (points) {
if (Array.isArray(points)) {
return datapoints
.filter(dp => points.some(p => p == dp.name)) //filter to the points that were passed in
.reduce((p, c) => { p[c.name] = c.value; return p; }, {}); //build up an array of the chosen points
}
else if (typeof points === 'string') return _.find(datapoints, d => d.name == points).value;
else throw ('readDataPoints requires a string, an array of strings, or nothing at all');
}
else
return datapoints.reduce((p, c) => p[c.name] = c.value, {});
}
startRecording(name?: string) {
name = name || moment().format('YYYY-MM-DD-HH-mm-ss');
this.recordingSubscription = this.reads$
.filter(r => r.type != 'pulse') //pulses are noisy
.subscribe(r => fs.appendFileSync(path.join(this.dataDirectory, name), JSON.stringify(r) + '\n'));
}
stopRecording(): void {
this.recordingSubscription.unsubscribe();
}
getRecordings(): string[] {
return fs.readdirSync(this.dataDirectory);
}
playRecording(name?: string): void {
name = name || 'simulationdata';
let lineReader = readline.createInterface({ input: fs.createReadStream(path.join(this.dataDirectory, name), { encoding: 'utf-8' }) });
let simdata$: Observable<ReadValue> = Observable.fromEvent<ReadValue>(lineReader, 'line')
.filter(value => (value ? true : false))
.map(value => JSON.parse(value.toString()))
let firstrow;
simdata$.subscribe(row => {
if (!firstrow) firstrow = row;
let delta = row.time - firstrow.time;
setTimeout(() => { this.reads$.next({ time: row.time, type: row.type, data: row.data }) }, delta);
});
}
startSimulation(): void {
this.playRecording();
}
/// set up new workout session on the WR with set distance
defineDistanceWorkout(distance: number, units: Units = Units.Meters): void {
this.send(`WSI${units}${ayb.decToHex(distance)}`);
}
/// set up new workout session on the WR with set duration
defineDurationWorkout(seconds: number): void {
this.send(`WSU${ayb.decToHex(seconds)}`);
}
/// change the display to meters, miles, kilometers, or strokes
displaySetDistance(units: Units): void {
let value = 'DD';
switch (units) {
case Units.Meters: value += 'ME'; break;
case Units.Miles: value += 'MI'; break;
case Units.Kilometers: value += 'KM'; break;
case Units.Strokes: value += 'ST'; break;
default: throw 'units must be meters, miles, kilometers, or strokes';
}
this.send(value);
}
/// change the intensity display
displaySetIntensity(option: IntensityDisplayOptions): void {
let value = 'DD';
switch (option) {
case IntensityDisplayOptions.MetersPerSecond: value += 'MS'; break;
case IntensityDisplayOptions.MPH: value += 'MPH'; break;
case IntensityDisplayOptions._500m: value += '500'; break;
case IntensityDisplayOptions._2km: value += '2KM'; break;
case IntensityDisplayOptions.Watts: value += 'WA'; break;
case IntensityDisplayOptions.CaloriesPerHour: value += 'CH'; break;
}
this.send(value);
}
/// change the average intensity display
displaySetAverageIntensity(option: AverageIntensityDisplayOptions): void {
let value = 'DD';
switch (option) {
case AverageIntensityDisplayOptions.AverageMetersPerSecond: value += 'MS'; break;
case AverageIntensityDisplayOptions.AverageMPH: value += 'MPH'; break;
case AverageIntensityDisplayOptions._500m: value += '500'; break;
case AverageIntensityDisplayOptions._2km: value += '2KM'; break;
default: throw 'units must be meters, miles, kilometers, or strokes';
}
this.send(value);
}
}
export interface WaterRowerOptions {
portName?: string;
baudRate?: number;
refreshRate?: number;
dataDirectory?: string;
datapoints?: string | string[];
}
export interface DataPoint {
time?: Date;
name?: string,
address: string,
length: string,
value: any
}
export interface ReadValue {
time: number
type: string
data: string
}
export enum IntensityDisplayOptions {
MetersPerSecond,
MPH,
_500m,
_2km,
Watts,
CaloriesPerHour
}
export enum AverageIntensityDisplayOptions {
AverageMetersPerSecond,
AverageMPH,
_500m,
_2km
}
export enum Units {
Meters = 1,
Miles = 2,
Kilometers = 3,
Strokes = 4
}