ptx2 / gymnasticon

Make obsolete and/or proprietary exercise bikes work with popular cycling training apps like Zwift, TrainerRoad, Rouvy and more.
https://ptx2.net/posts/unbricking-a-bike-with-a-raspberry-pi
MIT License
299 stars 39 forks source link

Echelon Support #27

Open vcipriani opened 4 years ago

vcipriani commented 4 years ago

Not sure the best way to track this, but I wanted to start a thread so that any interested parties can share info. Echelon's $500 connect sport bike seems to be extremely popular and I just purchased one. I'm hoping to start trying to reverse engineer it over the holiday but this is a bit out of my wheelhouse. It would be awesome to have Zwift support for it.

If anyone else makes progress on this front, please share info here (or let me know if there is a more appropriate place to discuss).

cagnulein commented 4 years ago

Hi @vcipriani it's very interesting! I'm running a similar project, if you're interested you can follow these steps and we will add the compatility https://github.com/cagnulein/qdomyos-zwift#your-machine-is-not-compatible

ptx2 commented 4 years ago

@vcipriani Maybe this is a starting point? https://github.com/UCF-SDTeam13/echelon-bike

doudar commented 4 years ago

I might have a great starting point using a cheap ESP 32. My project takes any spin bike with an old style knob resistance control and converts it to a Smart Bike. It's getting pretty full featured and has a web configuration interface. Hardware design is included.

https://github.com/doudar/SmartSpin2k

vcipriani commented 3 years ago

Unfortunately, I haven't had time to do anything with this yet. However, another project has deserialized the Echelon messages so adding support here just got easier. https://github.com/snowzach/echbt/blob/9541f1350196edd34982b7be9f569992b8f6efcd/echbt.ino#L27

cagnulein commented 3 years ago

I already implemented on my app https://github.com/cagnulein/qdomyos-zwift

ghost commented 3 years ago

Hey @ptx2!

Thanks for this awesome repository! I really love the idea and love put into this tool!

I've been trying now for several days and many hours getting Echelon support going with the info others provided here and with the debugging JS tool that you outlined in your blog post to this great tool, unfortunately, I wasn't able to get it to work and was hoping you might have some input.

It seems to me that noble isn't quite doing what it should since it seems to only ever show the very first Service UUID of any BLE device no matter if that isn't the right service, it won't discover the device or connect to it.

Checking with Bluetility, the Echelon Connect Sport Bike provides 2 services, the first one doesn't seem to do anything and the second seems to be structured as a UART similarly to your Flywheel bike with one TX and one RX stream.

Screen Shot 2021-02-06 at 19 45 27

Yet, noble doesn't want to connect to the second service which is the actual UART service since noble doesn't even discover the service through discover (only the first) and I can only connect to the bike when identifying the bike through it's mac address. Initially I though it was an issue with my hardware, but I've verified it on a rpi4 with arch linux as well as raspbian and then with an rpi0w with your image and the node and npm in the bin directory.

Do you have any insight if there's a way around this or this fix this behavior?

Thanks!

ptx2 commented 3 years ago

Hey, thanks for the kind words @EfficientSetting! Excited to hear you are working on Echelon support.

Can you share the noble code that's not working? What output does noble's example peripheral-explorer.js give?

ghost commented 3 years ago

I think it's easiest if I paste the full terminal output and write my observations underneath:

root@gymnasticon:/opt/noble/examples# node advertisement-discovery.js 
peripheral discovered (e46ed2eed01a with address <e4:6e:d2:ee:d0:1a, random>, connectable true, RSSI -72:
    hello my local name is:
        ECH-SPORT-058398
    can I interest you in any of the following advertised services:
        ["0bf669f045f211e795980800200c9a66"]

root@gymnasticon:/opt/noble/examples# node peripheral-explorer.js 0BF669F145F211E795980800200C9A66
^Croot@gymnasticon:/opt/noble/examples# node peripheral-explorer-async.js 0BF669F145F211E795980800200C9A66
^Croot@gymnasticon:/opt/noble/examples# node peripheral-explorer.js e4:6e:d2:ee:d0:1a
peripheral with ID e46ed2eed01a found
  Local Name        = ECH-SPORT-058398
  Service Data      = []
  Service UUIDs     = 0bf669f045f211e795980800200c9a66

services and characteristics:
^Croot@gymnasticon:/opt/noble/examples# node peripheral-explorer-async.js e4:6e:d2:ee:d0:1a
Peripheral with ID e46ed2eed01a found
  Local Name        = ECH-SPORT-058398
  Service Data      = []
  Service UUIDs     = 0bf669f045f211e795980800200c9a66

Services and characteristics:
1800 (Generic Access)
  2a00 (Device Name)
    properties  read, write
    value       4543482d53504f52542d303538333938 | 'ECH-SPORT-058398'
  2a01 (Appearance)
    properties  read
    value       0000 | ''
  2a04 (Peripheral Preferred Connection Parameters)
    properties  read
    value       0600180000009001 | ''
  2aa6 (Central Address Resolution)
    properties  read
    value       01 | ''
1801 (Generic Attribute)
  2a05 (Service Changed)
    properties  indicate
0bf669f045f211e795980800200c9a66
0bf669f145f211e795980800200c9a66
  0bf669f245f211e795980800200c9a66
    properties  writeWithoutResponse, write
  0bf669f345f211e795980800200c9a66
    properties  notify
  0bf669f445f211e795980800200c9a66
    properties  notify
180a (Device Information)
  2a29 (Manufacturer Name String)
    properties  read
    value       4368616e67596f77 | 'ChangYow'
  2a24 (Model Number String)
    properties  read
    value       534930303430 | 'SI0040'
  2a25 (Serial Number String)
    properties  read
    value       30302e3030 | '00.00'
  2a27 (Hardware Revision String)
    properties  read
    value       30302e30302e30302e3030 | '00.00.00.00'
  2a26 (Firmware Revision String)
    properties  read
    value       3031 | '01'
fe59
  8ec90003f3154f609fb8838830daea50
    properties  write, indicate
root@gymnasticon:/opt/noble/examples# cd ../../btdebugger/
root@gymnasticon:/opt/btdebugger# rmate btdebugger.mjs 
root@gymnasticon:/opt/btdebugger# cat btdebugger.mjs 
/**
 * Connect to the Flywheel Home Bike's Bluetooth UART service and log
 * received data to the console.
 */

import noble from '@abandonware/noble';
import {on, once} from 'events';

(async () => {
  // nordic uart service and characteristics
  const uuid = '0bf669f145f211e795980800200c9a66';
  const rxUuid = '0bf669f345f211e795980800200c9a66';
  const txUuid = '0bf669f445f211e795980800200c9a66';

  // wait for adapter
  const [state] = await once(noble, 'stateChange');
  if (state !== 'poweredOn') {
    throw new Error(`bluetooth adapter state ${state}`);
  }

  // scan
  await noble.startScanningAsync([uuid], false);
  const [peripheral] = await once(noble, 'discover');
  await noble.stopScanningAsync();

  // connect
  await peripheral.connectAsync();
  const {characteristics: [tx, rx]} = await
      peripheral.discoverSomeServicesAndCharacteristicsAsync([uuid],
      [txUuid, rxUuid]);

  // start receiving
  await tx.subscribeAsync();
  const packets = on(tx, 'read');

  // exit on ctrl-c
  let exit = false;
  process.on('SIGINT', () => { exit = true; });

  // print all received data
  const start = new Date();
  for await (const [packet] of packets) {
    if (exit)
      break;

    const t = new Date() - start;
    const mm = `${Math.floor(t/60)}`.padStart(2, '0');
    const ss = `${t % 60}`.padStart(2, '0');
    const mmss = `${mm}:${ss}`;
    console.log(mmss, packet);
  }

  // stop receiving
  await tx.unsubscribeAsync();

  // disconnect
  await peripheral.disconnectAsync();
})();
root@gymnasticon:/opt/btdebugger# node btdebugger.mjs 
(node:9476) ExperimentalWarning: The ESM module loader is experimental.
^C

Note: The gymnasticon host is a rpi0w with the image from this repo. The node binary is the node binary in the gymnasticon folder, just added to the path of the root user for convenience.

If you look at the advertisement data, noble seems to think that the Echelon bike only advertises the first service and nothing else. This seems to go along with what I found here: https://github.com/abandonware/noble/issues/99 or at least I think so. The newer hci binaries seem to be incompatible, but I don't know for sure.

Due to this error, connecting to this peripheral with the service UUID of my target service doesn't work. The only way to connect to the bike with noble seems to be through it's mac address, which won't work for anyone but me (or only through the macaddress option). Interestingly services and characteristics are only shown on connect through the async peripheral-explorer-async.js and not the non async one. Whenever I aborted a running command with ^C then it was quite a while in because the process didn't actually continue/deliver.

Would be interesting to hear if you faced similar issues or have any insight @ptx2.

Thanks

chriselsen commented 3 years ago

Looking at the reference link that ptx2 already posted above, the relevant data that you are looking for seems to be in ServiceUUID 1801 /AttributeUUID 0bf669f4-45f2-11e7-9598-0800200c9a66.

That UUID has the capability of "notify", not "read". Therefore you might want to look at the work done in #41, which subscribes to such a notify UUID.

But in the case of the Echelon bike, the values are then differently encoded within the Notify message.

One challenge that I see, is that the Echelon bike doesn't seem to transmit the power, but only the resistance level. You will therefore have to calculate - via formula or table - the power based on cadence and resistance level. See here for how the other above mentioned project has implemented that calculation.

ghost commented 3 years ago

@chriselsen Thanks for the reply. The problem is not finding the right GATT UUID, I figured that out after subscribing to the all UUIDs that offered to notify subscribers and seeing that this was the only one that actually gave any response. But the problem is with the noble library that is used in this project, since the library seems to be only connecting to BLE devices using their published services and due to a dependency issue that the library relies on, it seems that the lib can only ever see the very first UUID a device publishes. The only alternative is to connect to the mac of the device and then query available UUIDs, but that's not as convenient obviously as going for the UUID that is wanted in first place.

I haven't had any problems going that route with Swift on an iOS app that I put together, but that's also what I do for a living hence I'm more comfortable with that which is why I was wondering if there's something I'm missing with noble ...

chriselsen commented 3 years ago

I'm not sure I understand your problem: It seem like the run of your peripheral-explorer-async.js scrip successfully identified the services and characteristics.

It's true that the peripheral-explorer.js example that comes with the noble library tries to identify the services and characteristics in a crude way and often fails doing so. But you don't have to use that at all.

As you said: You know the desired service and characteristic UUID. Try to subscribe to it and see what data you get back: Does this return you any data?

var noble = require('@abandonware/noble');

noble.on('stateChange', function(state) {
  if (state === 'poweredOn') {
    noble.startScanning(["1801"]);
  } else {
    noble.stopScanning();
  }
});

noble.on('discover', function(peripheral) {
  // Once peripheral is discovered, stop scanning
  noble.stopScanning();

  peripheral.connect(function(error){
    var serviceUUID = ["1801"];
    var characteristicUUID = ["0bf669f445f211e79598-0800200c9a66"];

    // use noble's discoverSomeServicesAndCharacteristics
    // scoped to the heart rate service and measurement characteristic
    peripheral.discoverSomeServicesAndCharacteristics(serviceUUID, characteristicUUID, function(error, services, characteristics){
      characteristics[0].notify(true, function(error){
        characteristics[0].on('data', function(data, isNotification){
          console.log('data is: ' + data);
        });
      });
    });
  });
});

If this works, great! Than we can look at mapping the returned data to cadence / resistance / power levels. If that script hangs, than you are facing the same issue as described here.

In that case, start with this pull request, change these two lines to your identified UUID above and replace these lines of code with:

    console.log("Echelon data: ", data);
    const power = 123;
    const cadence = 45;
    return {power, cadence};

While this doesn't yet map the raw data from the Echelon bike, it should at least spit it out on the console when you paddle. Post the result and we can take it from there.

ghost commented 3 years ago

@chriselsen Thanks for your reply, unfortunately, your code outputs exactly nothing, which is the whole issue I've been trying to explain. Discovery/Scan on noble is simply not returning those UUIDs on any of the test devices I mentioned before.

I'm well aware how Bluetooth works and that this should be working with any other bluetooth library, but as it stands, it seems that noble isn't working correctly with the underlying software dependencies and therefore doesn't work with certain btle devices. Not a problem with this tool itself, I'm aware, but since this also happens with the rpi image provided here both on an rpi 4 and rpi0w, I was hoping that's an issue that the author might seen before and found a more elegant workaround than I currently have in mind.

If you look at the advertisement discovery output:

root@gymnasticon:/opt/noble/examples# node advertisement-discovery.js 
peripheral discovered (e46ed2eed01a with address <e4:6e:d2:ee:d0:1a, random>, connectable true, RSSI -72:
    hello my local name is:
        ECH-SPORT-058398
    can I interest you in any of the following advertised services:
        ["0bf669f045f211e795980800200c9a66"]

Noble only finds the very first service, not the second one or any the manufacturer data or anything else. This first service that noble finds, is of no use to use since it doesn't give us the data we want.

We want service 0bf669f145f211e795980800200c9a66 which is the second service in the screenshot I posted from Bluetility.

Now if we want to connect to this service, since we know the service exists (either from the Bluetility tool or using bluez or anything like it) then we should be able to connect to it using either the peripheral-explorer.js or the peripheral-explorer-async.js right?

root@gymnasticon:/opt/noble/examples# node peripheral-explorer.js 0BF669F145F211E795980800200C9A66

^C

root@gymnasticon:/opt/noble/examples# node peripheral-explorer-async.js 0BF669F145F211E795980800200C9A66

Both commands didn't work as they returned nothing - since noble doesn't see these advertised services as established earlier.

Only if we connect to the mac address of the bike directly, we'll be getting the service discovery you've been mentioning works successfully:

root@gymnasticon:/opt/noble/examples# node peripheral-explorer.js e4:6e:d2:ee:d0:1a
peripheral with ID e46ed2eed01a found
  Local Name        = ECH-SPORT-058398
  Service Data      = []
  Service UUIDs     = 0bf669f045f211e795980800200c9a66

services and characteristics:
^Croot@gymnasticon:/opt/noble/examples# node peripheral-explorer-async.js e4:6e:d2:ee:d0:1a
Peripheral with ID e46ed2eed01a found
  Local Name        = ECH-SPORT-058398
  Service Data      = []
  Service UUIDs     = 0bf669f045f211e795980800200c9a66

Services and characteristics:
1800 (Generic Access)
  2a00 (Device Name)
    properties  read, write
    value       4543482d53504f52542d303538333938 | 'ECH-SPORT-058398'
  2a01 (Appearance)
    properties  read
    value       0000 | ''
  2a04 (Peripheral Preferred Connection Parameters)
    properties  read
    value       0600180000009001 | ''
  2aa6 (Central Address Resolution)
    properties  read
    value       01 | ''
1801 (Generic Attribute)
  2a05 (Service Changed)
    properties  indicate
0bf669f045f211e795980800200c9a66
0bf669f145f211e795980800200c9a66
  0bf669f245f211e795980800200c9a66
    properties  writeWithoutResponse, write
  0bf669f345f211e795980800200c9a66
    properties  notify
  0bf669f445f211e795980800200c9a66
    properties  notify
180a (Device Information)
  2a29 (Manufacturer Name String)
    properties  read
    value       4368616e67596f77 | 'ChangYow'
  2a24 (Model Number String)
    properties  read
    value       534930303430 | 'SI0040'
  2a25 (Serial Number String)
    properties  read
    value       30302e3030 | '00.00'
  2a27 (Hardware Revision String)
    properties  read
    value       30302e30302e30302e3030 | '00.00.00.00'
  2a26 (Firmware Revision String)
    properties  read
    value       3031 | '01'
fe59
  8ec90003f3154f609fb8838830daea50
    properties  write, indicate

Although note: The synchronous discovery doesn't work as it returned nothing, only the async one did.

I think this has to do with https://github.com/abandonware/noble/issues/99 that others mentioned too. I've been able to work around the issue by simply making the connect to the unusable service 0bf669f045f211e795980800200c9a66 and then discover the characteristics that I now I need and that give me the data I want.

ptx2 commented 3 years ago

Hey @EfficientSetting sorry for the slow reply here!

I have not actually run into that issue before. It seems the service is not advertised but becomes available after connecting?

To dig deeper I would capture a trace while running Bluetility and another while running noble and compare the advertising, connection and service discovery packets between the two to see if anything jumps out. You could use PacketLogger to capture a trace on macOS and btmon on Linux.

It sounds like you managed to work around though?

doudar commented 3 years ago

@ptx2 , We've got it working over at https://github.com/doudar/SmartSpin2k/tree/Echelon . Feel free to take a look and use it here if you need. If that link goes dead, it's because we've merged the echelon tree into master.

The advertised UUID is #define ECHELON_DEVICE_UUID BLEUUID("0bf669f0-45f2-11e7-9598-0800200c9a66")

Then once you connect, the service you need is #define ECHELON_SERVICE_UUID BLEUUID("0bf669f1-45f2-11e7-9598-0800200c9a66")

Then you need to write byte message[] = {0xF0, 0xB0, 0x01, 0x01, 0xA2}; to characteristic #define ECHELON_WRITE_UUID BLEUUID("0bf669f2-45f2-11e7-9598-0800200c9a66") to tell the Echelon to broadcast the data.

Then you read the data on characteristic #define ECHELON_DATA_UUID BLEUUID("0bf669f4-45f2-11e7-9598-0800200c9a66")

The next trick is that it alternates between two different data broadcasts, one for resistance and the other with cadence. Only after you've seen both broadcasts can you calculate power. We do it with this:

void EchelonData::decode(uint8_t data, size_t length) { switch (data[1]) { // Cadence notification case 0xD1: this->cadence = int((data[9] << 8) + data[10]); break; // Resistance notification case 0xD2: this->resistance = int(data[3]); break; } if (isnan(this->cadence) || this->resistance < 0) { return; } if (this->cadence == 0 || this->resistance == 0) { power = 0; } else { power = pow(1.090112, resistance) pow(1.015343, cadence) * 7.228958; } }

ptx2 commented 3 years ago

Thanks @doudar!

ptx2 commented 3 years ago

Just pushed up experimental support for Echelon if anyone with the bike wants to try: https://github.com/ptx2/gymnasticon/pull/76