• Hi everybody and esp. Gordon (thanks for Espruino!!),

    I'm looking into receiving sensor readings from the commercially sold CSC sensor Wahoo Speed (https://www.amazon.com/Wahoo-Cycling-Spe­ed-Sensor-Bluetooth/dp/B01DIE7LUG/ref=sr­_1_3?s=sporting-goods&ie=UTF8&qid=153245­8958&sr=1-3&keywords=wahoo+speed) which came with my bike computer Wahoo ELEMNT Mini, of which I want to replace the cumbersome headunit.

    The sensors sends data via BLE with the CSC service:

    https://blog.bluetooth.com/part-2-the-wh­eels-on-the-bike-are-bluetooth-smart-blu­etooth-smart-bluetooth-smart

    https://www.bluetooth.com/specifications­/gatt/viewer?attributeXmlFile=org.blueto­oth.characteristic.csc_measurement.xml

    Using NRF connect on Android, the sensor readings are shown easily after enabling the notification.

    Now to the crucial part (for me):
    This code:

    var gatt;
    NRF.requestDevice({ filters: [{ namePrefix: 'Wahoo' }] }).then(function(device) {
      console.log(device);
      return device.gatt.connect();
    }).then(function(g) {
      gatt = g;
      return gatt.getPrimaryService("1816");
    }).then(function(service) {
      console.log("Service:" + service);
      return service.getCharacteristic("0x2A5B");
    }).then(function(characteristic) {
      return console.log(characteristic);
    }).then(function() {
      gatt.disconnect();
      console.log("Done!");
    });
    

    gives me this output:

     ____                 _
    |  __|___ ___ ___ _ _|_|___ ___
    |  __|_ -| . |  _| | | |   | . |
    |____|___|  _|_| |___|_|_|_|___|
             |_| espruino.com
     1v99 (c) 2018 G.Williams
    >
    =undefined
    BluetoothDevice: {
      "id": "df:b3:22:19:bd:8e random",
      "rssi": -60,
      "data": new Uint8Array([2, 1, 6, 3, 3, 22, 24, 17, 9, 87, 97, 104, 111, 111, 32, 83, 80, 69, 69, 68, 32, 54, 51, 70, 49]).buffer,
      "name": "Wahoo SPEED 63F1",
      "services": [
        "1816"
       ]
     }
    Service:[object Object]
    BluetoothRemoteGATTCharacteristic: {
      "uuid": "0x2a5b",
      "handle_value": 36, "handle_decl": 35,
      "properties": { "broadcast": false, "read": false, "writeWithoutResponse": false, "write": false,
        "notify": true, "indicate": false, "authenticatedSignedWrites": false }
     }
    Done!
    

    Can anyone point me in the right direction how I can enable and receive the CSC measurement? Sorry I'm a dummy with programming/JavaScript...
    The path to go I imagine would probably consist of writing to 0x2902 characteristic to enable notifications and have some kind of callback receive and interpret the data.

    Later on I plan on using the new MDBT42Q Breakout, connecting a Waveshare epaper display and 3d print a small case and have it running on a 18650 battery. Another option would be to use Pixl.js with a custom case and some kind of battery managment.

    Thanks for any help!!
    Best regards,

    Joost

  • Hi Joost,

    That looks really promising - and it's great that the Wahoo sensor uses a standard Bluetooth LE format for its data as well!

    I think you just want to start notifications on the characteristic - check out http://www.espruino.com/Reference#l_Blue­toothRemoteGATTCharacteristic_startNotif­ications - there's some example code there that should do exactly what you want.

    var gatt;
    NRF.requestDevice({ filters: [{ namePrefix: 'Wahoo' }] }).then(function(device) {
      console.log(device);
      return device.gatt.connect();
    }).then(function(g) {
      gatt = g;
      return gatt.getPrimaryService("1816");
    }).then(function(service) {
      console.log("Service:" + service);
      return service.getCharacteristic("0x2A5B");
    }).then(function(characteristic) {
      characteristic.on('characteristicvaluech­anged', function(event) {
        console.log("-> "+event.target.value);
      });
      return characteristic.startNotifications();
    }).then(function() {
      console.log("Done!");
      // Then call gatt.disconnect(); if/when you want to disconnect
    });
    

    event.target.value should be a DataView so you should be able to pull out the relevant bits of the characteristic pretty easily.

  • Cool, thanks a lot! Will try this later tonight, J

  • As a quick follow up, this works!

    var last_wheel_event_time = 0, actual_wheel_event_time;
    var last_cumulative_wheel_revolutions = 0, cumulative_wheel_revolutions;
    var delta_revolutions, delta_event_time;
    var wheel_circumference = 2340;
    var delta_time_in_ms;
    
    
    function OnNotify(event) {
        //console.log("-> "+(event.target.value.getUint8(0, true)>>> 0).toString(2)); //characteristics flags
        cumulative_wheel_revolutions = event.target.value.getUint32(1, true);
        actual_wheel_event_time = event.target.value.getUint16(5, true);
        console.log("Cumulative wheel Revolutions: "+ cumulative_wheel_revolutions);
        console.log("Last Wheel Event time: "+ actual_wheel_event_time + " ms");
        delta_revolutions = cumulative_wheel_revolutions - last_cumulative_wheel_revolutions;
        //console.log("-> "+event.target.value.getUint16(7, true)); //crank
        //console.log("-> "+event.target.value.getUint16(9, true)); //crank
      
      	if (actual_wheel_event_time >= last_wheel_event_time) {
    				delta_event_time = actual_wheel_event_time - last_wheel_event_time;
    		} else {
    				delta_event_time = 65536 - last_wheel_event_time + actual_wheel_event_time;
    			}
        delta_time_in_ms =(delta_event_time / 1.024);
      console.log(delta_event_time);
        console.log("delta_time_in_ms:" + delta_time_in_ms);
      last_wheel_event_time = actual_wheel_event_time; last_cumulative_wheel_revolutions = cumulative_wheel_revolutions;
    }
    
    var gatt;
    NRF.requestDevice({ timeout:20000, filters: [{ namePrefix: 'Wahoo' }] }).then(function(device) {
      console.log(device);
      return device.gatt.connect();
    }).then(function(g) {
      gatt = g;
      return gatt.getPrimaryService("1816");
    }).then(function(service) {
      //console.log("Service:" + service);
      return service.getCharacteristic("0x2A5B");
    }).then(function(characteristic) {
      characteristic.on('characteristicvaluech­anged', OnNotify);
      return characteristic.startNotifications();
    }).then(function() {
      console.log("Done!");
      // Then call gatt.disconnect(); if/when you want to disconnect
    });
    

    Thanks a lot, J

  • That's great! Thanks for posting the code up.

    I'd be really interested to see what you make with it!

  • Hey Joost! (Thanks Gordon for pointing me here!)

    This looks like a brilliant bit of kit - and making your own head unit sounds like an awesome project. I'd be really interested in hearing updates too.

    I'm planning to make a tracker which would only check values every hour (I'm planning to capture bike usage over several months). Do the cumulative revolutions increase whilst you're not connected? If so, I'm definitely going to pick up one of these.

  • Hi Benjamin,

    the Wahoo Speed Sensor resets its "cumulative wheel revolutions" counter when switching off, so you will have to monitor the transmitted data continously with enabling the notifications for the service.

    As it seems, we have similar goals with creating an unobtrusive device. Right now I did not advance, as I am awaiting the delivery of the MDBT42 Breakout board, which I ordered just vey recently. I think it will arrive next week (international shipping). The code above was tested with a Puck.js by the way.
    My plan is to add a small LiPo battery (around 300-500mAh), a small LiPo charger and an ePaper display. My device will be switched on/active when cycling, I also want it to update the displayed data every couple of seconds. Perhaps it might be an option to add an IMU or vibration sensor to let it sleep when the bike is parked, and awaken when moving the bike - that'd be cool. I guess I will check with Gordonif he has any comments/hints for a very low power state with the bluetooth radio switched off, just monitoring a GPIO.

  • Hi! Just sitting there monitoring a GPIO with setWatch is pretty low-power - your idea of using a vibration sensor is nice and easy. There are some Power consumption figures for Puck.js here - http://www.espruino.com/Puck.js#power-co­nsumption

    Only thing to watch out for is you want a vibration sensor that is normally not shorted. If it's shorted then just the current across the pullup resistor will probably make up the majority of your power draw!

  • Thanks Gordon, I ordered one of those SW-18020P (e.g. https://www.sunrom.com/p/vibration-senso­r ), hopeful it'll work!

  • Hi,

    anyone knows why this code

    function setDisplayMode(displaymode) {
     print("setDisplayMode: ", displaymode);
     switch (displaymode) {
             case "switchToConnected":
               Pixl.menu();
               break;
             case "connecting":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               Pixl.menu();
               connect();
               break;
             case "livedata":
               print("livedata");
               Pixl.menu();
               if (timer==-1) {
                 print("Set timer"); 
                 timer = setInterval( function () {showLiveData(); }, 1000); 
                 print("timer = ", timer);
               }
               break;
             case "menu":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               m=Pixl.menu(mainmenu);
               g.setFont8x12();
               m.draw();
               }
    }
    

    gives me this runtime error?

    >setDisplayMode:  menu
    =undefined
    setDisplayMode:  connecting
    Starting scan...
    Uncaught SyntaxError: BREAK statement outside of SWITCH, FOR or WHILE loop
     at line 119 col 12
               break;
               ^
    in function "setDisplayMode" called from line 1 col 28
    setDisplayMode("connecting");
                               ^
    in function "b" called from line 1 col 48
    var b=d[c[a.selected]];"function"==typeof b&&b()
                                                   ^
    in function "select" called from line 1 col 10
    b.select()
             ^
    in function called from system
    

    I looked it up a hundred times, this should be correct switch/case syntax, or is it not?

  • Can you show a little bit more of the code? I wonder about the connect being some async/callback thing that messes with you... Worst case - comes push to shove - it may be that the (async) code executing 'in' connect(... makes the JS interpreter loose the context and returns unable to match the break with the switch it looks to be in... (if we can trust the console output - time sync correctly...). Try this far shot by wrapping connect() into a setTimeout(...: setTimeout(connect,1); (connect() must be a globally / level 0 defined function of yours, is it?).

    Btw, you can simply say if (timer) timer = clearInterval(timer); (and conversely if (!timer) { ... - in line 15) with globally declaring var timer;.

    What are you using the timer for? ...update the display every second?

  • Thanks for your answer!

    Here is the complete code:

    var actual_menu, gatt;
    var last_wheel_event_time = 0, actual_wheel_event_time;
    var last_cumulative_wheel_revolutions = 0, actual_cumulative_wheel_revolutions = 0, track_cumulative_revolutions = 0;
    var delta_revolutions, delta_event_time, timer = -1;
    var wheel_circumference = 2340;
    var delta_time_in_ms, connected = false;
    
    function connect() {
      print("Starting scan...");
      g.clear();
      var s = "Connecting...";
      g.drawString(s ,0 ,0); g.flip();
      try {
      NRF.requestDevice({ timeout:20000, filters: [{ namePrefix: 'Wahoo' }] }).then(function(device) {
      console.log(device);
      return device.gatt.connect();
      }).then(function(gatt_object) {
        gatt = gatt_object;
        s = "Looking for service...";
        g.clear(); g.drawString(s ,0 ,0); g.flip();
        return gatt.getPrimaryService("1816");
        }).then(function(service) {
          s = "Looking for char...";
          g.clear(); g.drawString(s ,0 ,0); g.flip();
          return service.getCharacteristic("0x2A5B");
        }).then(function(characteristic) {
          characteristic.on('characteristicvaluech­anged', OnNotify);
          return characteristic.startNotifications();
          }).then(function() {
            connected = true;
            setDisplayMode("livedata");
            console.log("Done!");
            // Then call gatt.disconnect(); if/when you want to disconnect
            });
      } catch(error) {
          print("Sensor not found", error);
          g.clear(); g.drawString("No sensor found..." ,0 ,0); g.flip();
        }
    }
    
    function disconnect() {
      if (gatt) {
          gatt.disconnect();
      }
      connected = false;
      print("Connected: ", connected);
      setDisplayMode("menu");
    }
    
    function OnNotify(event) {
        //console.log("-> "+(event.target.value.getUint8(0, true)>>> 0).toString(2)); //characteristics flags
        actual_cumulative_wheel_revolutions = event.target.value.getUint32(1, true);
        actual_wheel_event_time = event.target.value.getUint16(5, true);
        //console.log("Cumulative wheel Revolutions: " + actual_cumulative_wheel_revolutions);
        //console.log("Last Wheel Event time: "+ actual_wheel_event_time + " ms");
        delta_revolutions = actual_cumulative_wheel_revolutions - last_cumulative_wheel_revolutions;
        //console.log("-> "+event.target.value.getUint16(7, true)); //crank
        //console.log("-> "+event.target.value.getUint16(9, true)); //crank
      	if (actual_wheel_event_time >= last_wheel_event_time) {
    				delta_event_time = actual_wheel_event_time - last_wheel_event_time;
    		} else {
    				delta_event_time = 65536 - last_wheel_event_time + actual_wheel_event_time;
    			}
        delta_time_in_ms =(delta_event_time / 1.024);
        //console.log("delta_time_in_ms:" + delta_time_in_ms);
        last_wheel_event_time = actual_wheel_event_time;
        last_cumulative_wheel_revolutions = actual_cumulative_wheel_revolutions;
        track_cumulative_revolutions += delta_revolutions;
    }
    
    var mainmenu = {
      "" : {
        "title" : "-- Main Menu --",
        "fontHeight" : 10
      },
    
      "Connect" : function () { setDisplayMode("connecting"); },
      "Disconnect" : function () { disconnect(); },
      "Live Data" : function() {setDisplayMode("livedata"); },
      "New Track" : function () {},
      "Toggle Backlight" : function() { LED1.toggle(); },
      "-> Settings" : function () {Pixl.menu(SettingsMenu); },
      //"Exit" : function() { Pixl.menu(); },
    };
    
    var SettingsMenu = {
      "" : {
        "title" : "-- Settings --",
        "fontHeight" : 10
      },
      "Set Wheel Circumference" : undefined, // do nothing
      "Set Total Distance Counter" : undefined, // do nothing
      "< Back" : function() { Pixl.menu(mainmenu); },
    };
    
    function showLiveData() {
      //print("showlivedata");
      g.clear();
      var s = "Cum. Wheel Revolutions: ";
      g.drawString(s ,0 ,0); //95 - g.stringWidth(s) g.getWidth()
      g.drawString(actual_cumulative_wheel_rev­olutions, 0, 11);
      var speed = delta_revolutions * wheel_circumference / delta_time_in_ms * 60 * 60 / 1000;
      s = "Speed: " + String(speed.toFixed(2));
      g.drawString(s, 0, 22);
      s = "Track distance: " + (track_cumulative_revolutions * wheel_circumference / 1000 / 1000).toFixed(2);
      g.drawString(s, 0, 33);
      g.flip();
    }
    
    function onInit () {
      require("Font8x12").add(Graphics);
      setDisplayMode("menu");
      setWatch(function () { setDisplayMode("menu"); }, BTN2, {repeat:true, debounce:50});
    }
    
    function setDisplayMode(displaymode) {
     print("setDisplayMode: ", displaymode);
     switch (displaymode) {
             case "switchToConnected":
               Pixl.menu();
               break;
             case "connecting":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               Pixl.menu();
               setTimeout(function() {connect();},1);
               break;
             case "livedata":
               print("livedata");
               Pixl.menu();
               if (timer==-1) {
                 print("Set timer"); 
                 timer = setInterval( function () {showLiveData(); }, 2000); 
                 print("timer = ", timer);
               }
               break;
             case "menu":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               m=Pixl.menu(mainmenu);
               g.setFont8x12();
               m.draw();
               }
    }
    
    onInit();
    
    

    Though sadly your idea with

    setTimeout(connect, 1);

    did not work out; returning the same error. Thanks again!

  • Thanks - I just checked on this and it's a bug in Pixl.menu in the Pixl.js firmware (it's slightly special as it is implemented in JS, but the act of executing it seems to mess with the execution state).

    I'll try and get a fix in for it, but in the mean time, add this code up the top of what you upload and it'll fix it:

    Pixl._menu = Pixl.menu;
    Pixl.menu = function(m) { Pixl._menu(m); };
    
  • Hi Gordon, thanks a lot!

    The switch/break error seems fixed; now something creeped in giving me

     ____                 _
    |  __|___ ___ ___ _ _|_|___ ___
    |  __|_ -| . |  _| | | |   | . |
    |____|___|  _|_| |___|_|_|_|___|
             |_| espruino.com
     1v99 (c) 2018 G.Williams
    >setDisplayMode:  menu
    Uncaught Error: Cannot read property 'draw' of undefined
     at line 143 col 20
               pixlmenu.draw();
                       ^
    in function "setDisplayMode" called from line 115 col 24
      setDisplayMode("menu");
                           ^
    in function "onInit" called from line 1 col 8
    onInit();
           ^
    

    (also not working if renaming pixlmenu back to "m", as I had in my code before).
    This destroys the larger menu font you helped me with yesterday.

    This being the code right now:

    var actual_menu, gatt;
    var last_wheel_event_time = 0, actual_wheel_event_time;
    var last_cumulative_wheel_revolutions = 0, actual_cumulative_wheel_revolutions = 0, track_cumulative_revolutions = 0;
    var delta_revolutions, delta_event_time, timer = -1;
    var wheel_circumference = 2340;
    var delta_time_in_ms, connected = false;
    
    Pixl._menu = Pixl.menu; //Bug Fix von Gordon, s. Forum
    Pixl.menu = function(m) { Pixl._menu(m); }; //Bug Fix
    
    function connect() {
      print("Starting scan...");
      g.clear();
      var s = "Connecting...";
      g.drawString(s ,0 ,0); g.flip();
      try {
      NRF.requestDevice({ timeout:20000, filters: [{ namePrefix: 'Wahoo' }] }).then(function(device) {
      console.log(device);
      return device.gatt.connect();
      }).then(function(gatt_object) {
        gatt = gatt_object;
        s = "Looking for service...";
        g.clear(); g.drawString(s ,0 ,0); g.flip();
        return gatt.getPrimaryService("1816");
        }).then(function(service) {
          s = "Looking for char...";
          g.clear(); g.drawString(s ,0 ,0); g.flip();
          return service.getCharacteristic("0x2A5B");
        }).then(function(characteristic) {
          characteristic.on('characteristicvaluech­anged', OnNotify);
          return characteristic.startNotifications();
          }).then(function() {
            connected = true;
            setDisplayMode("livedata");
            console.log("Done!");
            // Then call gatt.disconnect(); if/when you want to disconnect
            });
      } catch(error) {
          print("Sensor not found", error);
          g.clear(); g.drawString("No sensor found..." ,0 ,0); g.flip();
        }
    }
    
    function disconnect() {
    ...
    }
    
    function OnNotify(event) {
        ...
    }
    
    var mainmenu = {
      "" : {
        "title" : "-- Main Menu --",
        "fontHeight" : 10
      },
    
      "Connect" : function () { setDisplayMode("connecting"); },
      "Disconnect" : function () { disconnect(); },
      "Live Data" : function() {setDisplayMode("livedata"); },
      "New Track" : function () {},
      "Toggle Backlight" : function() { LED1.toggle(); },
      "-> Settings" : function () {Pixl.menu(SettingsMenu); },
      //"Exit" : function() { Pixl.menu(); },
    };
    
    var SettingsMenu = {
      "" : {
        "title" : "-- Settings --",
        "fontHeight" : 10
      },
      "Set Wheel Circumference" : undefined, // do nothing
      "Set Total Distance Counter" : undefined, // do nothing
      "< Back" : function() { Pixl.menu(mainmenu); },
    };
    
    function showLiveData() {
     
    }
    
    function onInit () {
      require("Font8x12").add(Graphics);
      setDisplayMode("menu");
      setWatch(function () { setDisplayMode("menu"); }, BTN2, {repeat:true, debounce:50});
    }
    
    function setDisplayMode(displaymode) {
     print("setDisplayMode: ", displaymode);
     switch (displaymode) {
             case "switchToConnected":
               Pixl.menu();
               break;
             case "connecting":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               Pixl.menu();
               connect();
               break;
             case "livedata":
               print("livedata");
               Pixl.menu();
               if (timer==-1) {
                 print("Set timer"); 
                 timer = setInterval( function () {showLiveData(); }, 2000); 
                 print("timer = ", timer);
               }
               break;
             case "menu":
               if (timer!==-1) {clearInterval(timer); timer = -1;}
               pixlmenu=Pixl.menu(mainmenu);
               g.setFont8x12();
               pixlmenu.draw();
               }
    }
    
    onInit();
    
    
  • Ahh, ok - try:

    Pixl._menu = Pixl.menu;
    Pixl.menu = function(m) { return Pixl._menu(m); };
    
  • That worked, great! Thanks a lot, J

  • Hi, I'm an engineering student trying to get an output from the wahoo cadence sensor but I am using an Arduino Uno and an Adafruit Bluefruit LE SPI Friend.

    I'd love some help in getting the code you have here working or adapted for use with arduino.
    Any help would be great! (I'm running mac too by the way, judge me as necessary...)

    Kieran

  • @kierankay, hat's a tough task you take onto you... the way that Arduino works - polling loop - versus Espruino - interrupt driven - puts itself in way of adapting this code. Furthermore, check on the implementation of Promises in Arduino world. Depending what the adafruit libs give you (setting some flags on filtered reception of data and buffer thereof) and availability of a lib or your on implementation of Promises you should be able to lean on the main line and structure of the Espruino code...

  • Post a reply
    • Bold
    • Italics
    • Link
    • Image
    • List
    • Quote
    • code
    • Preview
About

WIP - Puck.js reading CSC (cycling speed cadence) BLE sensor to replace bike computer head unit with Puck.js or (later) Pixl.js

Posted by Avatar for Joost @Joost

Actions