1
0
Fork 0

Cycling: Initial commit

master
Joseph Paul 2022-03-20 09:25:43 +01:00
parent bbd2cac110
commit 31d835dad5
8 changed files with 761 additions and 0 deletions

1
apps/cycling/ChangeLog Normal file
View File

@ -0,0 +1 @@
0.01: Initial version

26
apps/cycling/README.md Normal file
View File

@ -0,0 +1,26 @@
# Cycling
> Displays data from a BLE Cycling Speed and Cadence sensor.
*Fork of the CSCSensor app using the layout library and separate module for CSC functionality*
The following data are displayed:
- curent speed
- moving time
- average speed
- maximum speed
- trip distance
- total distance
Total distance is not stored on the Bangle, but instead is calculated from the CWR (cumulative wheel revolutions) reported by the sensor. This metric is, according to the BLE spec, a absolute value that persists throughout the lifetime of the sensor and never rolls over.
**Cadence / Crank features are currently not implemented**
# TODO
* Settings: imperial/metric
* Store circumference per device address
* Sensor battery status
* Implement crank events / show cadence
* Bangle.js 1 compatibility
# Development
There is a "mock" version of the `blecsc` module, which can be used to test features in the emulator. Check `blecsc-emu.js` for usage.

111
apps/cycling/blecsc-emu.js Normal file
View File

@ -0,0 +1,111 @@
// UUID of the Bluetooth CSC Service
const SERVICE_UUID = "1816";
// UUID of the CSC measurement characteristic
const MEASUREMENT_UUID = "2a5b";
// Wheel revolution present bit mask
const FLAGS_WREV_BM = 0x01;
// Crank revolution present bit mask
const FLAGS_CREV_BM = 0x02;
/**
* Fake BLECSC implementation for the emulator, where it's hard to test
* with actual hardware. Generates "random" wheel events (no crank).
*
* To upload as a module, paste the entire file in the console using this
* command: require("Storage").write("blecsc-emu",`<FILE CONTENT HERE>`);
*/
class BLECSCEmulator {
constructor() {
this.timeout = undefined;
this.interval = 500;
this.ccr = 0;
this.lwt = 0;
this.handlers = {
// value
// disconnect
// wheelEvent
// crankEvent
};
}
getDeviceAddress() {
return 'fa:ke:00:de:vi:ce';
}
/**
* Callback for the GATT characteristicvaluechanged event.
* Consumers must not call this method!
*/
onValue(event) {
// Not interested in non-CSC characteristics
if (event.target.uuid != "0x" + MEASUREMENT_UUID) return;
// Notify the generic 'value' handler
if (this.handlers.value) this.handlers.value(event);
const flags = event.target.value.getUint8(0, true);
// Notify the 'wheelEvent' handler
if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({
cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions
lwet: event.target.value.getUint16(5, true), // last wheel event time
});
// Notify the 'crankEvent' handler
if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({
ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions
lcet: event.target.value.getUint16(9, true), // last crank event time
});
}
/**
* Register an event handler.
*
* @param {string} event value|disconnect
* @param {function} handler handler function that receives the event as its first argument
*/
on(event, handler) {
this.handlers[event] = handler;
}
fakeEvent() {
this.interval = Math.max(50, Math.min(1000, this.interval + Math.random()*40-20));
this.lwt = (this.lwt + this.interval) % 0x10000;
this.ccr++;
var buffer = new ArrayBuffer(8);
var view = new DataView(buffer);
view.setUint8(0, 0x01); // Wheel revolution data present bit
view.setUint32(1, this.ccr, true); // Cumulative crank revolutions
view.setUint16(5, this.lwt, true); // Last wheel event time
this.onValue({
target: {
uuid: "0x2a5b",
value: view,
},
});
this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval);
}
/**
* Find and connect to a device which exposes the CSC service.
*
* @return {Promise}
*/
connect() {
this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval);
return Promise.resolve(true);
}
/**
* Disconnect the device.
*/
disconnect() {
if (!this.timeout) return;
clearTimeout(this.timeout);
}
}
exports = BLECSCEmulator;

150
apps/cycling/blecsc.js Normal file
View File

@ -0,0 +1,150 @@
const SERVICE_UUID = "1816";
// UUID of the CSC measurement characteristic
const MEASUREMENT_UUID = "2a5b";
// Wheel revolution present bit mask
const FLAGS_WREV_BM = 0x01;
// Crank revolution present bit mask
const FLAGS_CREV_BM = 0x02;
/**
* This class communicates with a Bluetooth CSC peripherial using the Espruino NRF library.
*
* ## Usage:
* 1. Register event handlers using the \`on(eventName, handlerFunction)\` method
* You can subscribe to the \`wheelEvent\` and \`crankEvent\` events or you can
* have raw characteristic values passed through using the \`value\` event.
* 2. Search and connect to a BLE CSC peripherial by calling the \`connect()\` method
* 3. To tear down the connection, call the \`disconnect()\` method
*
* ## Events
* - \`wheelEvent\` - the peripharial sends a notification containing wheel event data
* - \`crankEvent\` - the peripharial sends a notification containing crank event data
* - \`value\` - the peripharial sends any CSC characteristic notification (including wheel & crank event)
* - \`disconnect\` - the peripherial ends the connection or the connection is lost
*
* Each event can only have one handler. Any call to \`on()\` will
* replace a previously registered handler for the same event.
*/
class BLECSC {
constructor() {
this.device = undefined;
this.ccInterval = undefined;
this.gatt = undefined;
this.handlers = {
// wheelEvent
// crankEvent
// value
// disconnect
};
}
getDeviceAddress() {
if (!this.device || !this.device.id)
return '00:00:00:00:00:00';
return this.device.id.split(" ")[0];
}
checkConnection() {
if (!this.device)
console.log("no device");
// else
// console.log("rssi: " + this.device.rssi);
}
/**
* Callback for the GATT characteristicvaluechanged event.
* Consumers must not call this method!
*/
onValue(event) {
// Not interested in non-CSC characteristics
if (event.target.uuid != "0x" + MEASUREMENT_UUID) return;
// Notify the generic 'value' handler
if (this.handlers.value) this.handlers.value(event);
const flags = event.target.value.getUint8(0, true);
// Notify the 'wheelEvent' handler
if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({
cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions
lwet: event.target.value.getUint16(5, true), // last wheel event time
});
// Notify the 'crankEvent' handler
if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({
ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions
lcet: event.target.value.getUint16(9, true), // last crank event time
});
}
/**
* Callback for the NRF disconnect event.
* Consumers must not call this method!
*/
onDisconnect(event) {
console.log("disconnected");
if (this.ccInterval)
clearInterval(this.ccInterval);
if (!this.handlers.disconnect) return;
this.handlers.disconnect(event);
}
/**
* Register an event handler.
*
* @param {string} event wheelEvent|crankEvent|value|disconnect
* @param {function} handler function that will receive the event as its first argument
*/
on(event, handler) {
this.handlers[event] = handler;
}
/**
* Find and connect to a device which exposes the CSC service.
*
* @return {Promise}
*/
connect() {
// Register handler for the disconnect event to be passed throug
NRF.on('disconnect', this.onDisconnect.bind(this));
// Find a device, then get the CSC Service and subscribe to
// notifications on the CSC Measurement characteristic.
// NRF.setLowPowerConnection(true);
return NRF.requestDevice({
timeout: 5000,
filters: [{ services: [SERVICE_UUID] }],
}).then(device => {
this.device = device;
this.device.on('gattserverdisconnected', this.onDisconnect.bind(this));
this.ccInterval = setInterval(this.checkConnection.bind(this), 2000);
return device.gatt.connect();
}).then(gatt => {
this.gatt = gatt;
return gatt.getPrimaryService(SERVICE_UUID);
}).then(service => {
return service.getCharacteristic(MEASUREMENT_UUID);
}).then(characteristic => {
characteristic.on('characteristicvaluechanged', this.onValue.bind(this));
return characteristic.startNotifications();
});
}
/**
* Disconnect the device.
*/
disconnect() {
if (this.ccInterval)
clearInterval(this.ccInterval);
if (!this.gatt) return;
try {
this.gatt.disconnect();
} catch {
//
}
}
}
exports = BLECSC;

View File

@ -0,0 +1,420 @@
const Layout = require('Layout');
const SETTINGS_FILE = 'cscsensor.json';
const storage = require('Storage');
const RECONNECT_TIMEOUT = 4000;
const MAX_CONN_ATTEMPTS = 2;
class CSCSensor {
constructor(blecsc, display) {
// Dependency injection
this.blecsc = blecsc;
this.display = display;
// Load settings
this.settings = storage.readJSON(SETTINGS_FILE, 1) || {};
this.wheelCirc = (this.settings.wheelcirc || 2230) / 1000; // unit: m
// CSC runtime variables
this.movingTime = 0; // unit: s
this.lastBangleTime = Date.now(); // unit: ms
this.lwet = 0; // last wheel event time (unit: s/1024)
this.cwr = -1; // cumulative wheel revolutions
this.cwrTrip = 0; // wheel revolutions since trip start
this.speed = 0; // unit: m/s
this.maxSpeed = 0; // unit: m/s
this.speedFailed = 0;
// Other runtime variables
this.connected = false;
this.failedAttempts = 0;
this.failed = false;
// Layout configuration
this.layout = 0;
this.display.useMetricUnits(true);
// this.display.useMetricUnits(!require("locale").speed(1).toString().endsWith("mph"));
}
onDisconnect(event) {
console.log("disconnected ", event);
this.connected = false;
this.setLayout(0);
this.display.setDeviceAddress("unknown");
if (this.failedAttempts >= MAX_CONN_ATTEMPTS) {
this.failed = true;
this.display.setStatus("Connection failed after " + MAX_CONN_ATTEMPTS + " attempts.");
} else {
this.display.setStatus("Disconnected");
setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT);
}
}
connect() {
this.connected = false;
this.setLayout(0);
this.display.setStatus("Connecting...");
console.log("Trying to connect to BLE CSC");
// Hook up events
this.blecsc.on('wheelEvent', this.onWheelEvent.bind(this));
this.blecsc.on('disconnect', this.onDisconnect.bind(this));
// Scan for BLE device and connect
this.blecsc.connect()
.then(function() {
this.failedAttempts = 0;
this.failed = false;
this.connected = true;
var addr = this.blecsc.getDeviceAddress();
console.log("Connected to " + addr);
this.display.setDeviceAddress(addr);
this.display.setStatus("Connected");
// Switch to speed screen in 2s
setTimeout(function() {
this.setLayout(1);
this.updateScreen();
}.bind(this), 2000);
}.bind(this))
.catch(function(e) {
this.failedAttempts++;
this.onDisconnect(e);
}.bind(this));
}
disconnect() {
this.blecsc.disconnect();
this.connected = false;
this.setLayout(0);
this.display.setStatus("Disconnected")
}
setLayout(num) {
this.layout = num;
if (this.layout == 0) {
this.display.updateLayout("status");
} else if (this.layout == 1) {
this.display.updateLayout("speed");
} else if (this.layout == 2) {
this.display.updateLayout("distance");
}
}
reset() {
this.connected = false;
this.failed = false;
this.failedAttempts = 0;
}
interact(d) {
// Only interested in tap / center button
if (d) return;
// Reconnect in failed state
if (this.failed) {
this.reset();
this.connect();
} else if (this.connected) {
this.setLayout((this.layout + 1) % 3);
}
}
updateScreen() {
var tripDist = this.cwrTrip * this.wheelCirc;
var avgSpeed = this.movingTime > 3 ? tripDist / this.movingTime : 0
this.display.setTotalDistance(this.cwr * this.wheelCirc);
this.display.setTripDistance(tripDist);
this.display.setSpeed(this.speed);
this.display.setAvg(avgSpeed);
this.display.setMax(this.maxSpeed);
this.display.setTime(Math.floor(this.movingTime));
}
onWheelEvent(event) {
// Calculate number of revolutions since last wheel event
var dRevs = (this.cwr > 0 ? event.cwr - this.cwr : 0);
this.cwr = event.cwr;
// Increment the trip revolutions counter
this.cwrTrip += dRevs;
// Calculate time delta since last wheel event
var dT = (event.lwet - this.lwet)/1024;
var now = Date.now();
var dBT = (now-this.lastBangleTime)/1000;
this.lastBangleTime = now;
if (dT<0) dT+=64; // wheel event time wraps every 64s
if (Math.abs(dT-dBT)>3) dT = dBT; // not sure about the reason for this
this.lwet = event.lwet;
// Recalculate current speed
if (dRevs>0 && dT>0) {
this.speed = dRevs * this.wheelCirc / dT;
this.speedFailed = 0;
this.movingTime += dT;
} else {
this.speedFailed++;
if (this.speedFailed>3) {
this.speed = 0;
}
}
// Update max speed
if (this.speed>this.maxSpeed
&& (this.movingTime>3 || this.speed<20)
&& this.speed<50
) this.maxSpeed = this.speed;
this.updateScreen();
}
}
class CSCDisplay {
constructor() {
this.metric = true;
this.fontLabel = "6x8";
this.fontMed = "15%";
this.fontLarge = "32%";
this.currentLayout = "status";
this.layouts = {};
this.layouts.speed = new Layout({
type: "v",
c: [
{
type: "h",
id: "speed_g",
fillx: 1,
filly: 1,
pad: 4,
bgCol: "#fff",
c: [
{type: undefined, width: 32, halign: -1},
{type: "txt", id: "speed", label: "00.0", font: this.fontLarge, bgCol: "#fff", col: "#000", width: 122},
{type: "txt", id: "speed_u", label: " km/h", font: this.fontLabel, col: "#000", width: 22, r: 90},
]
},
{
type: "h",
id: "time_g",
fillx: 1,
pad: 4,
bgCol: "#000",
height: 32,
c: [
{type: undefined, width: 32, halign: -1},
{type: "txt", id: "time", label: "00:00", font: this.fontMed, bgCol: "#000", col: "#fff", width: 122},
{type: "txt", id: "time_u", label: "mins", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 22, r: 90},
]
},
{
type: "h",
id: "stats_g",
fillx: 1,
bgCol: "#fff",
height: 32,
c: [
{
type: "v",
pad: 4,
bgCol: "#fff",
c: [
{type: "txt", id: "max_l", label: "MAX", font: this.fontLabel, col: "#000"},
{type: "txt", id: "max", label: "00.0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 69},
],
},
{
type: "v",
pad: 4,
bgCol: "#fff",
c: [
{type: "txt", id: "avg_l", label: "AVG", font: this.fontLabel, col: "#000"},
{type: "txt", id: "avg", label: "00.0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 69},
],
},
{type: "txt", id: "stats_u", label: " km/h", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90},
]
},
],
});
this.layouts.distance = new Layout({
type: "v",
c: [
{
type: "h",
id: "tripd_g",
fillx: 1,
pad: 4,
bgCol: "#fff",
height: 32,
c: [
{type: "txt", id: "tripd_l", label: "TRP", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36},
{type: "txt", id: "tripd", label: "0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 118},
{type: "txt", id: "tripd_u", label: "km", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90},
]
},
{
type: "h",
id: "totald_g",
fillx: 1,
pad: 4,
bgCol: "#000",
height: 32,
c: [
{type: "txt", id: "totald_l", label: "TTL", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 36},
{type: "txt", id: "totald", label: "0", font: this.fontMed, bgCol: "#000", col: "#fff", width: 118},
{type: "txt", id: "totald_u", label: "km", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 22, r: 90},
]
},
],
});
this.layouts.status = new Layout({
type: "v",
c: [
{
type: "h",
id: "status_g",
fillx: 1,
bgCol: "#fff",
height: 100,
c: [
{type: "txt", id: "status", label: "Bangle Cycling", font: this.fontMed, bgCol: "#fff", col: "#000", width: 176, wrap: 1},
]
},
{
type: "h",
id: "addr_g",
fillx: 1,
pad: 4,
bgCol: "#fff",
height: 32,
c: [
{ type: "txt", id: "addr_l", label: "MAC", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36 },
{ type: "txt", id: "addr", label: "unknown", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 140 },
]
},
],
});
}
updateLayout(layout) {
this.currentLayout = layout;
g.clear();
this.layouts[layout].update();
this.layouts[layout].render();
}
renderIfLayoutActive(layout, node) {
if (layout != this.currentLayout) return;
this.layouts[layout].render(node)
}
useMetricUnits(metric) {
this.metric = metric;
console.log("using " + (metric ? "metric" : "imperial") + " units");
var speedUnit = metric ? "km/h" : "mph";
this.layouts.speed.speed_u.label = speedUnit;
this.layouts.speed.stats_u.label = speedUnit;
var distanceUnit = metric ? "km" : "mi";
this.layouts.distance.tripd_u.label = distanceUnit;
this.layouts.distance.totald_u.label = distanceUnit;
this.updateLayout(this.currentLayout);
}
convertDistance(meters) {
if (this.metric) return meters / 1000;
return meters / 1609.344;
}
convertSpeed(mps) {
if (this.metric) return mps * 3.6;
return mps * 2.23694;
}
setSpeed(speed) {
this.layouts.speed.speed.label = this.convertSpeed(speed).toFixed(1);
this.renderIfLayoutActive("speed", this.layouts.speed.speed_g);
}
setAvg(speed) {
this.layouts.speed.avg.label = this.convertSpeed(speed).toFixed(1);
this.renderIfLayoutActive("speed", this.layouts.speed.stats_g);
}
setMax(speed) {
this.layouts.speed.max.label = this.convertSpeed(speed).toFixed(1);
this.renderIfLayoutActive("speed", this.layouts.speed.stats_g);
}
setTime(seconds) {
var time = '';
var hours = Math.floor(seconds/3600);
if (hours) {
time += hours + ":";
this.layouts.speed.time_u.label = " hrs";
} else {
this.layouts.speed.time_u.label = "mins";
}
time += String(Math.floor((seconds%3600)/60)).padStart(2, '0') + ":";
time += String(seconds % 60).padStart(2, '0');
this.layouts.speed.time.label = time;
this.renderIfLayoutActive("speed", this.layouts.speed.time_g);
}
setTripDistance(distance) {
this.layouts.distance.tripd.label = this.convertDistance(distance).toFixed(1)
this.renderIfLayoutActive("distance", this.layouts.distance.tripd_g);
}
setTotalDistance(distance) {
this.layouts.distance.totald.label = this.convertDistance(distance).toFixed(1)
this.renderIfLayoutActive("distance", this.layouts.distance.totald_g);
}
setDeviceAddress(address) {
this.layouts.status.addr.label = address
this.renderIfLayoutActive("status", this.layouts.status.addr_g);
}
setStatus(status) {
this.layouts.status.status.label = status
this.renderIfLayoutActive("status", this.layouts.status.status_g);
}
}
var BLECSC;
if (process.env.BOARD === "EMSCRIPTEN" || process.env.BOARD === "EMSCRIPTEN2") {
// Emulator
BLECSC = require("blecsc-emu");
} else {
// Actual hardware
BLECSC = require("blecsc");
}
var blecsc = new BLECSC();
var display = new CSCDisplay();
var sensor = new CSCSensor(blecsc, display);
E.on('kill',()=>{
sensor.disconnect();
});
Bangle.setUI("updown", d => {
sensor.interact(d);
});
sensor.connect();
// Bangle.loadWidgets();
// Bangle.drawWidgets();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,16 @@
{
"id": "cycling",
"name": "Bangle Cycling",
"shortName": "Cycling",
"version": "0.01",
"description": "Display live values from a BLE CSC sensor",
"icon": "icons8-cycling-48.png",
"tags": "outdoors,exercise,ble,bluetooth",
"supports": ["BANGLEJS2"],
"readme": "README.md",
"storage": [
{"name":"cycling.app.js","url":"cycling.app.js"},
{"name":"cycling.settings.js","url":"settings.js"},
{"name":"blecsc","url":"blecsc.js"}
]
}

37
apps/cycling/settings.js Normal file
View File

@ -0,0 +1,37 @@
// This file should contain exactly one function, which shows the app's settings
/**
* @param {function} back Use back() to return to settings menu
*/
(function(back) {
const SETTINGS_FILE = 'cscsensor.json'
// initialize with default settings...
let s = {
'wheelcirc': 2230,
}
// ...and overwrite them with any saved values
// This way saved values are preserved if a new version adds more settings
const storage = require('Storage')
const saved = storage.readJSON(SETTINGS_FILE, 1) || {}
for (const key in saved) {
s[key] = saved[key];
}
// creates a function to safe a specific setting, e.g. save('color')(1)
function save(key) {
return function (value) {
s[key] = value;
storage.write(SETTINGS_FILE, s);
}
}
const menu = {
'': { 'title': 'Cycle speed sensor' },
'< Back': back,
'Wheel circ.(mm)': {
value: s.wheelcirc,
min: 800,
max: 2400,
step: 5,
onchange: save('wheelcirc'),
},
}
E.showMenu(menu);
})