VanMoof integration (ESPHome/BTProxy Presence+API)

Still no finished product, just showcasing the implementation

As I got one of those proprietary dutch bikes :bike: I wanted to implement it in a way in my homeassistant dashboard (Even the picture is generated from the API)

Presence detection (typycally i store it in the basement, where it can be charged etc.) so i added two lines to a BT-Proxy device (PoESP32/M5 Stack, but that could be anything else)

ESPHome-BT-Config
substitutions:
  vanmoof_s5_mac: "12:34:56:C0:FF:EE"
# Bluetooth settings
esp32_ble_tracker:
  scan_parameters:
    duration: 10s # https://github.com/esphome/issues/issues/1408#issuecomment-696834035

ble_client:
  - id: ble_vanmoof_s5
    auto_connect: false
    # No autoconnect, just a couple of checks: https://esphome.io/components/ble_client.html#ble-client-connect-action
    mac_address: ${vanmoof_s5_mac}
    on_connect:
      then:
        - lambda: |-
            ESP_LOGD("BLE", "VanMoof S5 verbunden");
    on_disconnect:
      then:
        - lambda: |-
            ESP_LOGD("BLE", "VanMoof S5 getrennt");

bluetooth_proxy:
  active: true # activate bluetooth proxy

binary_sensor:
  - platform: ble_presence
    id: ble_vanmoof_s5_presence
    name: "BLE Presence Vanmoof S5"
    icon: "mdi:bike"
    mac_address: ${vanmoof_s5_mac}

So that results in not only the device to be triangulated (if used) by Bermuda BLE, but also to be detected in HA-Cards:

But now we will face the API in order to gain insights on the bike speed and odometer.
I assume that you want to fetch data for one bike only (so that could be simplified), if you got multiple bikes, you can do it more sophisticated with regex and search for the serialnumber.
Requirement: please add the vm-token as a secret. It can be generated using shell/powershell etc: generate vm-token
its just the username and password as base64:

printf ‘%s:%s’ “$username” “$password” | base64

configuration.yaml
rest:
  - resource: "https://my.vanmoof.com/api/v8/authenticate"
    #scan_interval: 28800 # 8h
    scan_interval: 14400 # 4h
    timeout: 30  # Reduzierter Timeout fĂźr bessere Reaktionszeit
    method: "POST"
    headers:
      Accept: "application/json"
      Authorization: !secret vm_token
      Api-Key: "fcb38d47-f14b-30cf-843b-26283f6a5819"
    sensor:
      - name: "VM Auth" # Token for Bike API and App Token
        unique_id: vanmoof_auth_token
        force_update: true
        value_template: "OK"
        json_attributes:
          - token
          - refreshToken
  - resource: "https://api.vanmoof-api.com/v8/getApplicationToken"
    scan_interval: 7200  # Alle 2 Stunden
    timeout: 30
    method: "GET"
    headers:
      Accept: "application/json"
      Authorization: "Bearer {{ state_attr('sensor.vm_auth', 'token') }}"
      Api-Key: "fcb38d47-f14b-30cf-843b-26283f6a5819"
    sensor:
      - name: "VM Application Token" # Used for rides API
        unique_id: vanmoof_app_token
        force_update: true
        value_template: "OK"
        json_attributes:
          - token
        availability: >
          {{ states.sensor.vm_auth.state == "OK" and 
             state_attr('sensor.vm_auth', 'token') is not none and
             (as_timestamp(now()) - as_timestamp(states.sensor.vm_auth.last_updated)) < 14400 }}
  - resource: "https://my.vanmoof.com/api/v8/getCustomerData?includeBikeDetails"
    scan_interval: 31536000 # 1Y
    timeout: 30
    method: "GET"
    headers:
      Accept: "application/json"
      Authorization: "Bearer {{ state_attr('sensor.vm_auth', 'token') }}"
      Api-Key: "fcb38d47-f14b-30cf-843b-26283f6a5819"
    sensor:
      - name: "VM API customer details"
        unique_id: vanmoof_api_cust
        value_template: "OK"
        json_attributes:
            - data
        availability: >
            {{ states.sensor.vm_auth.state == "OK" and 
            state_attr('sensor.vm_auth', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_auth.last_updated)) < 14400 }}
      - name: "VM User UUID"
        unique_id: vanmoof_data_uuid
        value_template: "{{ state_attr('sensor.vm_api_customer_details', 'data')['uuid'] }}"
        availability: >
            {{ states.sensor.vm_auth.state == "OK" and 
            state_attr('sensor.vm_auth', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_auth.last_updated)) < 14400 }}
      - name: "VM Bike0 ID"
        unique_id: vanmoof_data_bikes_0_id
        value_template: "{{ state_attr('sensor.vm_api_customer_details', 'data')['bikes'][0]['id'] }}"
        availability: >
            {{ states.sensor.vm_auth.state == "OK" and 
            state_attr('sensor.vm_auth', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_auth.last_updated)) < 14400 }}
      - name: "VM Bike0 frame number"
        unique_id: vanmoof_data_bikes_0_frame_number
        value_template: "{{ state_attr('sensor.vm_api_customer_details', 'data')['bikes'][0]['frameNumber'] }}"
        availability: >
            {{ states.sensor.vm_auth.state == "OK" and 
            state_attr('sensor.vm_auth', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_auth.last_updated)) < 14400 }}
  - resource_template: https://bikeapi.production.vanmoof.cloud/bikes/{{ states('sensor.vm_bike0_frame_number') }}/create_certificate
    scan_interval: 604800 # 1W
    timeout: 30
    method: "POST"
    payload: '{ "public_key" : "xxx }'
    headers:
      Accept: "*/*"
      Authorization: "Bearer {{ state_attr('sensor.vm_application_token', 'token') }}"
      Content-Type: "application/json"
    sensor:
      - name: "VM bike certificate"
        unique_id: vanmoof_api_bike_cert
        value_template: "OK"
        json_attributes:
            - created_at
            - expiry
            - certificate
        availability: >
            {{ states('sensor.vm_bike0_frame_number') is not none and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
  - resource_template: https://tenjin.vanmoof.com/api/v1/rides/{{ states('sensor.vm_user_uuid') }}/{{ states('sensor.vm_bike0_id') }}/weekly?limit=15
    scan_interval: 14400 # 8h
    timeout: 30
    method: "GET"
    headers:
      Accept: "*/*"
      Authorization: "Bearer {{ state_attr('sensor.vm_application_token', 'token') }}"
      Api-Key: "fcb38d47-f14b-30cf-843b-26283f6a5819"
      Time-Zone: "Europe/Berlin"
      Accept-Language: "de_DE"
    sensor:
      - name: "VM ride stats"
        unique_id: vanmoof_api_rides
        icon: mdi:counter
        value_template: "OK"
        json_attributes:
            - carousel
        availability: >
            {{ states('sensor.vm_user_uuid') is not none and
            states('sensor.vm_bike0_id') is not none and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
      - name: "VM total rides"
        unique_id: vanmoof_total_rides
        value_template: "{{ state_attr('sensor.vm_ride_stats', 'carousel')['summary']['totalRides'] }}"
        state_class: "total_increasing"
        availability: >
            {{ states.sensor.vm_application_token.state == "OK" and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
      - name: "VM total distance"
        unique_id: vanmoof_total_distance
        unit_of_measurement: 'km'
        icon: mdi:map-marker-distance
        value_template: "{{ state_attr('sensor.vm_ride_stats', 'carousel')['summary']['totalDistance'] }}"
        state_class: "total_increasing"
        availability: >
            {{ states.sensor.vm_application_token.state == "OK" and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
      - name: "VM average speed"
        unique_id: vanmoof_average_speed
        unit_of_measurement: 'kmh'
        icon: mdi:speedometer
        value_template: "{{ state_attr('sensor.vm_ride_stats', 'carousel')['summary']['averageSpeed'] }}"
        availability: >
            {{ states.sensor.vm_application_token.state == "OK" and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
      - name: "VM average distance"
        unique_id: vanmoof_average_distance
        unit_of_measurement: 'km'
        icon: mdi:map-marker-distance
        value_template: "{{ state_attr('sensor.vm_ride_stats', 'carousel')['summary']['averageDistance'] }}"
        availability: >
            {{ states.sensor.vm_application_token.state == "OK" and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}
      - name: "VM average duration"
        unique_id: vanmoof_average_duration
        unit_of_measurement: 'min'
        icon: mdi:timer-check-outline
        value_template: "{{ (state_attr('sensor.vm_ride_stats', 'carousel')['summary']['averageDuration'] | int / 60000) }}"
        availability: >
            {{ states.sensor.vm_application_token.state == "OK" and 
            state_attr('sensor.vm_application_token', 'token') is not none and
            (as_timestamp(now()) - as_timestamp(states.sensor.vm_application_token.last_updated)) < 7200 }}

You can now fetch the bike/ride stats accordingly. The API is built with different authorization tokens.

  • There is one bearer-token, which is provided after signin in using the base64-ed username/password
    Authorization: "Bearer {{ state_attr('sensor.vm_auth', 'token') }}"
  • Another one is the application token, used for the ride api, but also to generate a certificate for the bike
    Authorization: "Bearer {{ state_attr('sensor.vm_application_token', 'token') }}"

I would suggest to fetch the tokens using automations. (If the bike is at home, there is no need to fetch the api more often, but if its outside, you might generate new statistics in the ride API.

This is still not a finished product. The battery might be fetched using ESPHome and the encryption key. Feel free to use this as an inspiration for further investigations.
I got into those API-Endpoints using mitm-proxy and my android/iphone devices.

1 Like

First of all: great work!

I noticed that the secret vm_token has to be written like: “Basic NpbHZlcmVudG…FuZEBnbWFpbC5jb206=”. With that I get a value in the sensor attribute of VM_auth. But then it stops… Other sensors state OK but the attributes are empty
image

When I test the rest commands online I get a result like { “token”: “abcdefghijklmnopqrstuvwxyz1234567890” }

Hey, when the Okay is written, then you will find it in the attributes. I do the fetch and refresh stuff using automations which I can also share!
The thing ist, upon first load there might not be tokens for the further actions, thats probably why the other steps fail.

Maybe there is someone who wants to do it as an integration, but thats some work, for sure.
Actually fetching the BLE Details with the encryption Key would be amazing. So far i was not able to dive into the sourcecodes, I just figured out how to do a simple BLE sensor, triangulation and the API fetch for stats.

Yes, interested in the automations!

I have different ones, here you go:

On boot
alias: Update VanMoof Tokens, APIs and cert (on Boot)
description: >-
  Fetch API on boot, this includes the Authorization Bearer, the application
  token, customer details, the ride API and the bike certificate
triggers:
  - trigger: homeassistant
    event: start
conditions: []
actions:
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_auth
  - delay:
      hours: 0
      minutes: 0
      seconds: 15
      milliseconds: 0
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_api_customer_details
        - sensor.vm_application_token
  - delay:
      hours: 0
      minutes: 0
      seconds: 15
      milliseconds: 0
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_ride_stats
        - sensor.vm_bike_certificater
mode: single
Regular API-fetch (8 hours)
alias: VM Api regular fetch
description: >-
  Fetch API every 8 hours, this includes the Authorization Bearer,  the
  application token, customer details and the ride API
triggers:
  - trigger: time_pattern
    hours: /8
conditions: []
actions:
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_auth
  - delay:
      hours: 0
      minutes: 0
      seconds: 15
      milliseconds: 0
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_api_customer_details
        - sensor.vm_application_token
  - delay:
      hours: 0
      minutes: 0
      seconds: 15
      milliseconds: 0
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_ride_stats
mode: single
Renew bike cert (7 days)
alias: Renew bike certificate
description: Fetch new bike certificate every 7 days.
triggers:
  - trigger: time_pattern
    hours: "0"
conditions:
  - condition: time
    weekday:
      - fri
actions:
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_bike_certificater
mode: single
Update ride stats
alias: Update ride stats
description: >-
  Fetch the ride stats, thats a standalone automation, as it is not related to
  other automations. (Can be triggered by others though)
triggers: []
conditions: []
actions:
  - action: homeassistant.update_entity
    metadata: {}
    data:
      entity_id:
        - sensor.vm_ride_stats
mode: single

So based on those automations, i use the ride-stats automtion for getting more accurate detailt. When the bike left my home, i can use another automation to fetch it e.g. each two hours.
Please also note the delay in the inital fetch ( On boot)