Maker.io main logo

Pyloton CircuitPython Cycling Computer

2023-10-17 | By Adafruit Industries

License: See Original Project

Courtesy of Adafruit

Guide by John Park

Overview

 

Pyloton is a CircuitPython bike computer made with the CLUE board. ‎Pyloton measures Bluetooth LE heart rate, speed, and cadence.‎

It also provides Apple Music Service song info, combined on one ‎small device with a sharp display and a 3D printed handlebar mount ‎‎(or optional wrist mount).‎

This project is the culmination of three previous Adafruit Learning ‎System projects, plus some new abilities of our Bluefruit code to ‎connect to multiple peripheral devices!‎‎ ‎ 

devices_1

devices_2

Previously, we created these standalone projects:‎

You may refer to those guides for additional details on the sensors, ‎libraries, and code. Here, we'll show you how they can all be ‎combined into one device.‎

one_3

one_4

Parts List

parts_5

parts_6

parts_7

parts_8

Parts

Third-Party Products

Heart Rate Monitor

You'll need a heart rate monitor that supports Bluetooth Low Energy ‎‎(BLE). I'm using the Scosche RHYTHM+ but you should be able to use ‎any monitor that uses the Bluetooth SIG Heart Rate service standard.‎

These work by flashing green (and sometimes yellow) LEDs against ‎your skin and then measuring the reflected light that returns. The ‎color changes/darkens during the pulse of your heart thanks to all ‎that blood sloshing around!‎

monitor_9

Cycling Speed & Cadence Sensor

Bluetooth LE compatible, such as the Wahoo Fitness Blue SC. This ‎type of device is two sensors in one package and typically reads ‎speed and cadence revolutions based on a pair of magnets affixed to ‎a spoke and crank.‎

You can also use individual speed and/or cadence sensors that use ‎an IMU rather than a magnet to sense revolutions, such as ‎the Wahoo RPM Speed and RPM Cadence.‎

Be sure that your sensors use Bluetooth LE and not Ant+ or some ‎other radio standard. (Some use both, which is fine.)‎

cycling_10

cycling_11

cycling_12

iOS Device

You'll also need an iPhone or iPod touch with BLE capabilities if you ‎want to see song playback info on the Pyloton.‎

device_13

Adafruit intends to have a comparable Android application for ‎Bluetooth monitoring. There currently is no time frame for the ‎release of an app. We regret the inconvenience.‎

Understanding BLE

bluetooth_14

BLE Basics

To understand how we communicate between the MagicLight Bulb ‎and the Circuit Playground Bluefruit (CPB), it's first important to get ‎an overview of how Bluetooth Low Energy (BLE) works in general.‎

The nRF52840 chip on the CPB uses Bluetooth Low Energy, or BLE. ‎BLE is a wireless communication protocol used by many devices, ‎including mobile devices. You can communicate between your CPB ‎and peripherals such as the Magic Light, mobile devices, and even ‎other CPB boards!‎

There are a few terms and concepts commonly used in BLE with ‎which you may want to familiarize yourself. This will help you ‎understand what your code is doing when you're using ‎CircuitPython and BLE.‎

Two major concepts to know about are the two modes of BLE ‎devices:‎

  • Broadcasting mode (also called GAP for Generic Access Profile)
  • Connected device mode (also ‎called GATT for Generic ATTribute Profile)

GAP mode deals with broadcasting peripheral advertisements, such ‎as "I'm a device named LEDBlue-19592CBC", as well as advertising ‎information necessary to establish a dedicated device connection if ‎desired. The peripheral may also be advertising available services.‎

GATT mode deals with communications and attribute transfer ‎between two devices once they are connected, such as between a ‎heart monitor and a phone, or between your CPB and the Magic ‎Light.‎

gap_15

Bluetooth LE Terms

GAP Mode

Device Roles:‎

  • Peripheral - The low-power device that broadcasts ‎advertisements. Examples of peripherals include heart rate ‎monitor, smart watch, fitness tracker, iBeacon, and the Magic ‎Light. The CPB can also work as a peripheral.
  • Central - The host "computer" that observes advertisements ‎being broadcast by the Peripherals. This is often a mobile ‎device such as a phone, tablet, desktop, or laptop, but the CPB ‎can also act as a central (which it will in this project).‎

Terms:‎

  • Advertising - Information sent by the peripheral before a ‎dedicated connection has been established. All nearby Centrals ‎can observe these advertisements. When a peripheral device ‎advertises, it may be transmitting the name of the device, ‎describing its capabilities, and/or some other piece of data. ‎Central can look for advertising peripherals to connect to and ‎use that information to determine each peripheral's capabilities ‎‎(or Services offered, more on that below).‎

GATT Mode

Device Roles:‎

  • Server - In connected mode, a device may take on a new role ‎as a Server, providing a Service available to clients. It can now ‎send and receive data packets as requested by the Client ‎device to which it now has a connection.
  • Client - In connected mode, a device may also take on a new ‎role as Client that can send requests to one or more of a ‎Server's available Services to send and receive data packets.‎

NOTE: A device in GATT mode can take on the role of both Server ‎and Client while connected to another device.‎

Terms:‎

  • Profile - A pre-defined collection of Services that a BLE device ‎can provide. For example, the Heart Rate Profile, or the Cycling ‎Sensor (bike computer) Profile. These Profiles are defined by ‎the Bluetooth Special Interest Group (SIG). For devices that ‎don't fit into one of the pre-defined Profiles, the manufacturer ‎creates their own Profile. For example, there is not a "Smart ‎Bulb" profile, so the Magic Light manufacturer has created their ‎own unique one.
  • Service - A function the Server provides. For example, a heart ‎rate monitor armband may have separate Services for Device ‎Information, Battery Service, and Heart Rate itself. Each ‎Service is comprised of collections of information ‎called Characteristics. In the case of the Heart Rate Service, ‎the two Characteristics are Heart RateMeasurement and Body Sensor Location. The peripheral ‎advertises its services.
  • Characteristic - A Characteristic is a container for the value, or ‎attribute, of a piece of data along with any associated ‎metadata, such as a human-readable name. A characteristic ‎may be readable, writable, or both. For example, the Heart Rate ‎Measurement Characteristic can be served up to the Client ‎device and will report the heart rate measurement as a ‎number, as well as the unit string "bpm" for beats-per-minute. ‎The Magic Light Server has a Characteristic for the RGB value of ‎the bulb which can be written to by the Central to change the ‎color. Characteristics each have a Universal Unique Identifier ‎‎(UUID) which is a 16-bit or 128-bit ID.
  • Packet - Data transmitted by a device. BLE devices and host ‎computers transmit and receive data in small bursts called ‎packets.‎

This guide is another good introduction to the concepts of BLE, ‎including GAP, GATT, Profiles, Services, and Characteristics.‎

CircuitPython on CLUE

CircuitPython is a derivative of MicroPython designed to simplify ‎experimentation and education on low-cost microcontrollers. It ‎makes it easier than ever to get prototyping by requiring no upfront ‎desktop software downloads. Simply copy and edit files on ‎the CIRCUITPY flash drive to iterate.‎

The following instructions will show you how to install CircuitPython. ‎If you've already installed CircuitPython but are looking to update it ‎or reinstall it, the same steps work for that as well!‎

Set up CircuitPython Quick Start!‎

Follow this quick step-by-step for super-fast Python power :)‎

Download the latest version of CircuitPython for CLUE from ‎circuitpython.org

Click the link above to download the latest version of ‎CircuitPython for the CLUE.‎

Download and save it to your desktop (or wherever is handy).‎

download_16

Plug your CLUE into your computer using a known-good USB cable.‎

A lot of people end up using charge-only USB cables and it is very ‎frustrating! So, make sure you have a USB cable you know is good ‎for data sync.‎

Double-click the Reset button on the top (magenta arrow) on your ‎board, and you will see the NeoPixel RGB LED (green arrow) turn ‎green. If it turns red, check the USB cable, try another USB port, ‎etc. Note: The little red LED next to the USB connector will pulse red. ‎That's ok!‎

If double-clicking doesn't work the first time, try again. Sometimes it ‎can take a few tries to get the rhythm right!‎

clue_17

You will see a new disk drive appear called CLUEBOOT.‎

Drag the adafruit-circuitpython-clue-etc.uf2 file to CLUEBOOT.‎

drive_18

drive_19

The LED will flash. Then, the CLUEBOOT drive will disappear, and a ‎new disk drive called CIRCUITPY will appear.‎

If this is the first time, you're installing CircuitPython or you're doing ‎a completely fresh install after erasing the filesystem, you will have ‎two files - boot_out.txt, and code.py, and one folder - lib on ‎your CIRCUITPY drive.‎

If CircuitPython was already installed, the files present before ‎reloading CircuitPython should still be present on ‎your CIRCUITPY drive. Loading CircuitPython will not create new ‎files if there was already a CircuitPython filesystem present.‎

That's it, you're done! :)‎

disk_20

Code the Pyloton in CircuitPython ‎for CLUE

To use with CircuitPython, you need to first install a few libraries, into ‎the lib folder on your CIRCUITPY drive. Then you need to ‎update code.py with the example script.‎

Thankfully, we can do this in one go. In the example below, click ‎the Download Project Bundle button below to download the ‎necessary libraries and the code.py file in a zip file. Extract the ‎contents of the zip file, open the ‎directory CircuitPython_Pyloton/ and then click on the directory ‎that matches the version of CircuitPython you're using and copy the ‎contents of that directory to your CIRCUITPY drive.‎

Your CIRCUITPY drive should now look similar to the following ‎image:‎

circuitpy_21

Code.py

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2020 Eva Herrada for Adafruit Industries
#
# SPDX-License-Identifier: MIT

from time import time
import adafruit_ble
import board
import pyloton

ble = adafruit_ble.BLERadio() # pylint: disable=no-member

CONNECTION_TIMEOUT = 45

HEART = True
SPEED = True
CADENCE = True
AMS = True
DEBUG = False

# 84.229 is wheel circumference (700x23 in my case)
pyloton = pyloton.Pyloton(ble, board.DISPLAY, 84.229, HEART, SPEED, CADENCE, AMS, DEBUG)

pyloton.show_splash()

ams = pyloton.ams_connect()


start = time()
hr_connection = None
speed_cadence_connections = []
while True:
if HEART:
if not hr_connection:
print("Attempting to connect to a heart rate monitor")
hr_connection = pyloton.heart_connect()
ble.stop_scan()
if SPEED or CADENCE:
if not speed_cadence_connections:
print("Attempting to connect to speed and cadence monitors")
speed_cadence_connections = pyloton.speed_cadence_connect()

if time()-start >= CONNECTION_TIMEOUT:
pyloton.timeout()
break
# Stop scanning whether or not we are connected.
ble.stop_scan()

#pylint: disable=too-many-boolean-expressions
if ((not HEART or (hr_connection and hr_connection.connected)) and
((not SPEED and not CADENCE) or
(speed_cadence_connections and speed_cadence_connections[0].connected)) and
(not AMS or (ams and ams.connected))):
break

pyloton.setup_display()

while ((not HEART or hr_connection.connected) and
((not SPEED or not CADENCE) or
(speed_cadence_connections and speed_cadence_connections[0].connected)) and
(not AMS or ams.connected)):
pyloton.update_display()
pyloton.ams_remote()

print("\n\nNot all sensors are connected. Please reset to try again\n\n")

 View on GitHub

Then, you can click on the Download: Project Zip link in the window ‎below to download the code file and .STL file.‎

Code Run Through

First, the code loads all the required libraries.‎

Download File

Copy Code
from time import time
import adafruit_ble
import board
import pyloton

Pyloton Setup

Then, the variables required to run Pyloton are defined. To disable a ‎specific sensor, set that sensor's variable to False. For example, if I ‎wanted to use AppleMediaServices and a speed and cadence ‎sensor, but didn't want to use a heart rate monitor, I would ‎set HEART to False and this section of code.py would look like this:‎

HEART = False

SPEED = True

CADENCE = True

AMS = True

DEBUG = False

After that, Pyloton is instantiated, and all the required variables are ‎passed into it. My bike has 700x23 tires on it, so my wheel diameter ‎is 84.229 inches. To calculate your wheel circumference, use the ‎chart here and then convert the diameter to inches. There are 2.54 ‎centimeters in one inch if you need to convert centimeters to inches.‎

Download File

Copy Code
ble = adafruit_ble.BLERadio()    # pylint: disable=no-member

CONNECTION_TIMEOUT = 45

HEART = True
SPEED = True
CADENCE = True
AMS = True
DEBUG = False

# 84.229 is wheel circumference (700x23 in my case)
pyloton = pyloton.Pyloton(ble, board.DISPLAY, 84.229, HEART, SPEED, CADENCE, AMS, DEBUG)

Preparing to Connect

At this stage, we first tell Pyloton to show the splash screen so we ‎can see the status of Pyloton easily.‎

Then, we tell Pyloton to connect to an Apple device. Simply ‎comment out that line and disable AMS if you don't intend on using ‎one. ‎

After that, we set the variable start to the current time so that the ‎code can tell when it started and time out if it’s taking too long.‎

Then, hr_connection and speed_cadence_connections are set to False so ‎that they don't cause a NameError when the setup loop checks the ‎status of the various sensors.‎

Download File

Copy Code
pyloton.show_splash()

ams = pyloton.ams_connect()

start = time()
hr_connection = None
speed_cadence_connections = []

Connecting loop

This While loop first checks if specific sensors are already connected. ‎If they aren't, and that sensor is enabled, it will run the functions ‎in pyloton.py to try to connect to those sensors.‎

Then, if more time than the desired timeout length has passed, 45 ‎seconds by default, it will break out of the loop and code.py will ‎finish running.‎

After that, a check if a sensor is connected only if it is enabled. If all ‎enabled sensors are connected, break out of the loop, and go to the ‎next section.‎

Download File

Copy Code
while True:
if HEART:
if not hr_connection:
print("Attempting to connect to a heart rate monitor")
hr_connection = pyloton.heart_connect()
ble.stop_scan()
if SPEED or CADENCE:
if not speed_cadence_connections:
print("Attempting to connect to speed and cadence monitors")
speed_cadence_connections = pyloton.speed_cadence_connect()

if time()-start >= CONNECTION_TIMEOUT:
pyloton.timeout()
break
# Stop scanning whether or not we are connected.
ble.stop_scan()

#pylint: disable=too-many-boolean-expressions
if ((not HEART or (hr_connection and hr_connection.connected)) and
((not SPEED and not CADENCE) or
(speed_cadence_connections and speed_cadence_connections[0].connected)) and
(not AMS or (ams and ams.connected))):
break

pyloton.setup_display()

Main loop

This loop uses the same logic as the lines above to determine if all ‎enabled sensors are connected. If they are, it then updates the ‎display and runs the ams_remote function. Comment out that line if ‎you aren't using a device with AppleMediaServices.‎

If one of the sensors disconnects, it breaks the loop and ends the file, ‎notifying the user that not all sensors are connected.‎

‎Download File

Copy Code
while ((not HEART or hr_connection.connected) and
((not SPEED or not CADENCE) or
(speed_cadence_connections and speed_cadence_connections[0].connected)) and
(not AMS or ams.connected)):
pyloton.update_display()
pyloton.ams_remote()

print("\n\nNot all sensors are connected. Please reset to try again\n\n")

Pyloton.py Run Through

To start off, we import all of Pyloton's dependencies.‎

Download File

Copy Code
import time
import adafruit_ble
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.advertising.standard import SolicitServicesAdvertisement
import board
import digitalio
import displayio
import adafruit_imageload
from adafruit_ble_cycling_speed_and_cadence import CyclingSpeedAndCadenceService
from adafruit_ble_heart_rate import HeartRateService
from adafruit_bitmap_font import bitmap_font
from adafruit_display_shapes.rect import Rect
from adafruit_display_text import label
from adafruit_ble_apple_media import AppleMediaService
from adafruit_ble_apple_media import UnsupportedCommand
import gamepad
import touchio

Clue library

Pyloton contains a small section of the Adafruit_CircuitPython_CLUE ‎library. We do this because the CLUE library has a lot of great tools ‎for getting data from sensors, but for Pyloton we only need functions ‎to get data from the buttons and capacitive touch pads. The CLUE ‎library is documented here.‎

Download File

Copy Code
class Clue:
"""
A very minimal version of the CLUE library.
The library requires the use of many sensor-specific
libraries this project doesn't use, and they were
taking up a lot of RAM.
"""
def __init__(self):
self._i2c = board.I2C()
self._touches = [board.D0, board.D1, board.D2]
self._touch_threshold_adjustment = 0
self._a = digitalio.DigitalInOut(board.BUTTON_A)
self._a.switch_to_input(pull=digitalio.Pull.UP)
self._b = digitalio.DigitalInOut(board.BUTTON_B)
self._b.switch_to_input(pull=digitalio.Pull.UP)
self._gamepad = gamepad.GamePad(self._a, self._b)

@property
def were_pressed(self):
"""
Returns a set of buttons that have been pressed since the last time were_pressed was run.
"""
ret = set()
pressed = self._gamepad.get_pressed()
for button, mask in (('A', 0x01), ('B', 0x02)):
if mask & pressed:
ret.add(button)
return ret

def _touch(self, i):
if not isinstance(self._touches[i], touchio.TouchIn):
self._touches[i] = touchio.TouchIn(self._touches[i])
self._touches[i].threshold += self._touch_threshold_adjustment
return self._touches[i].value

@property
def touch_0(self):
"""
Returns True when capacitive touchpad 0 is currently being pressed.
"""
return self._touch(0)

@property
def touch_1(self):
"""
Returns True when capacitive touchpad 1 is currently being pressed.
"""
return self._touch(1)

@property
def touch_2(self):
"""
Returns True when capacitive touchpad 2 is currently being pressed.
"""
return self._touch(2)

Pyloton Class

Here, Pyloton is defined. We then create some class variables used ‎for setting the colors in the UI and create a Clue object.‎

Download File

Copy Code
class Pyloton:
"""
Contains the various functions necessary for doing the Pyloton learn guide.
"""
#pylint: disable=too-many-instance-attributes

YELLOW = 0xFCFF00
PURPLE = 0x64337E
WHITE = 0xFFFFFF

clue = Clue()

‎__init__‎

Init first defines all of the required instance variables. It then loads ‎fonts, loads the sprite sheet, and sets up the group used to display ‎status update text.‎

Download File

Copy Code
def __init__(self, ble, display, circ, heart=True, speed=True, cad=True, ams=True, debug=False): #pylint: disable=too-many-arguments
self.debug = debug
self.ble = ble
self.display = display
self.circumference = circ

self.heart_enabled = heart
self.speed_enabled = speed
self.cadence_enabled = cad
self.ams_enabled = ams
self.hr_connection = None

self.num_enabled = heart + speed + cad + ams

self._previous_wheel = 0
self._previous_crank = 0
self._previous_revolutions = 0
self._previous_rev = 0
self._previous_speed = 0
self._previous_cadence = 0
self._previous_heart = 0
self._speed_failed = 0
self._cadence_failed = 0
self._setup = 0
self._hr_label = None
self._sp_label = None
self._cadence_label = None
self._ams_label = None
self._hr_service = None
self._heart_y = None
self._speed_y = None
self._cadence_y = None
self._ams_y = None
self.ams = None
self.cyc_connections = None
self.cyc_services = None
self.track_artist = True

self.start = time.time()

self.splash = displayio.Group()
self.loading_group = displayio.Group()

self._load_fonts()

self.sprite_sheet, self.palette = adafruit_imageload.load("/sprite_sheet.bmp",
bitmap=displayio.Bitmap,
palette=displayio.Palette)

self.text_group = displayio.Group()
self.status = label.Label(font=self.arial12, x=10, y=200,
text='', color=self.YELLOW)
self.status1 = label.Label(font=self.arial12, x=10, y=220,
text='', color=self.YELLOW)

self.text_group.append(self.status)
self.text_group.append(self.status1)

show_splash

Show splash is used to load and display the splash screen ‎‎(essentially a loading screen) using displayio. If debug is enabled, the ‎splash screen will not be displayed. ‎

Download File

Copy Code
def show_splash(self):
"""
Shows the loading screen
"""
if self.debug:
return

blinka_bitmap = "blinka-pyloton.bmp"

# Compatible with CircuitPython 6 & 7
with open(blinka_bitmap, 'rb') as bitmap_file:
bitmap1 = displayio.OnDiskBitmap(bitmap_file)
tile_grid = displayio.TileGrid(bitmap1, pixel_shader=getattr(bitmap1, 'pixel_shader', displayio.ColorConverter()))
self.loading_group.append(tile_grid)
self.display.show(self.loading_group)
status_heading = label.Label(font=self.arial16, x=80, y=175,
text="Status", color=self.YELLOW)
rect = Rect(0, 165, 240, 75, fill=self.PURPLE)
self.loading_group.append(rect)
self.loading_group.append(status_heading)

# # Compatible with CircuitPython 7+
# bitmap1 = displayio.OnDiskBitmap(blinka_bitmap)
# tile_grid = displayio.TileGrid(bitmap1, pixel_shader=bitmap1.pixel_shader)
# self.loading_group.append(tile_grid)
# self.display.show(self.loading_group)
# status_heading = label.Label(font=self.arial16, x=80, y=175,
# text="Status", color=self.YELLOW)
# rect = Rect(0, 165, 240, 75, fill=self.PURPLE)
# self.loading_group.append(rect)
# self.loading_group.append(status_heading)

‎_load_fonts‎

‎_load_fonts do two things. First, it loads the 3 fonts we will be using, ‎‎12 pt. Arial, 16 pt. Arial, and 24 pt. Arial bold. Then, it loads a bytestring ‎of commonly used characters. Loading these characters ahead of ‎time is not required, however it does speed up the display process ‎quite a bit.‎

Download File

Copy Code
def _load_fonts(self):
"""
Loads fonts
"""
self.arial12 = bitmap_font.load_font("/fonts/Arial-12.bdf")
self.arial16 = bitmap_font.load_font("/fonts/Arial-16.bdf")
self.arial24 = bitmap_font.load_font("/fonts/Arial-Bold-24.bdf")

glyphs = b'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-!,. "\'?!'
self.arial12.load_glyphs(glyphs)
self.arial16.load_glyphs(glyphs)
self.arial24.load_glyphs(glyphs)

_status_update‎

_status_update allows editing the text ‎of self.status and self.status1 to display the current status update ‎on the CLUE's screen. When message is longer than 25 characters, it ‎gets sliced, with the first 25 characters appearing on the first line and ‎the 25th through 50th characters appearing on the second line. ‎If debug is enabled, it will simply print the full message to the REPL.‎‎ ‎

Download File

Copy Code
def _status_update(self, message):
"""
Displays status updates
"""
if self.debug:
print(message)
return
if self.text_group not in self.loading_group:
self.loading_group.append(self.text_group)
self.status.text = message[:25]
self.status1.text = message[25:50]

timeout

This function is called by code.py when the CONNECTION_TIMEOUT has ‎been reached. It displays a message that the program has timed out ‎for three seconds, and then the program presumably ends.‎

Download File

Copy Code
def timeout(self):
"""
Displays Timeout on screen when pyloton has been searching for a sensor for too long
"""
self._status_update("Pyloton: Timeout")
time.sleep(3)

heart_connect

Heart connects use the circuitpython_ble_heart_rate library to ‎connect to a heart rate monitor. This is a modified version of ‎the ble_heart_rate_simpletest.py file.‎

Download File

Copy Code
def heart_connect(self):
"""
Connects to heart rate sensor
"""
self._status_update("Heart Rate: Scanning...")
for adv in self.ble.start_scan(ProvideServicesAdvertisement, timeout=5):
if HeartRateService in adv.services:
self._status_update("Heart Rate: Found an advertisement")
self.hr_connection = self.ble.connect(adv)
self._status_update("Heart Rate: Connected")
break
self.ble.stop_scan()
if self.hr_connection:
self._hr_service = self.hr_connection[HeartRateService]
return self.hr_connection

ams_connect

This function is used to connect to an Apple device using ‎the ble_apple_media library. It is based on the example code ‎from ble_apple_media_simpletest.py.‎

Download File

Copy Code
def ams_connect(self, start=time.time(), timeout=30):
"""
Connect to an Apple device using the ble_apple_media library
"""
self._status_update("AppleMediaService: Connect your phone now")
radio = adafruit_ble.BLERadio()
a = SolicitServicesAdvertisement()
a.solicited_services.append(AppleMediaService)
radio.start_advertising(a)

while not radio.connected and not self._has_timed_out(start, timeout):
pass

self._status_update("AppleMediaService: Connected")
for connection in radio.connections:
if not connection.paired:
connection.pair()
self._status_update("AppleMediaService: Paired")
self.ams = connection[AppleMediaService]

return radio

speed_cadence_connect

This function uses the ble_cycling_speed_and_cadence library to ‎connect to speed and cadence sensors. It works with both discrete ‎speed and cadence sensors and combined speed and cadence ‎sensors. It will not work if you were to connect two of the same ‎sensor type, as in two speed sensors or a speed and cadence sensor ‎and a speed sensor. This is based on the example ‎file, ble_cycling_speed_and_cadence_simpletest.py.‎

Download File

Copy Code
def speed_cadence_connect(self):
"""
Connects to speed and cadence sensor
"""
self._status_update("Speed and Cadence: Scanning...")
# Save advertisements, indexed by address
advs = {}
for adv in self.ble.start_scan(ProvideServicesAdvertisement, timeout=5):
if CyclingSpeedAndCadenceService in adv.services:
self._status_update("Speed and Cadence: Found an advertisement")
# Save advertisement. Overwrite duplicates from same address (device).
advs[adv.address] = adv

self.ble.stop_scan()
self._status_update("Speed and Cadence: Stopped scanning")
if not advs:
# Nothing found. Go back and keep looking.
return []

# Connect to all available CSC sensors.
self.cyc_connections = []
for adv in advs.values():
self.cyc_connections.append(self.ble.connect(adv))
self._status_update("Speed and Cadence: Connected {}".format(len(self.cyc_connections)))

self.cyc_services = []
for conn in self.cyc_connections:
self.cyc_services.append(conn[CyclingSpeedAndCadenceService])
self._status_update("Pyloton: Finishing up...")

return self.cyc_connections

‎_compute_speed‎

A function used to turn the total number of revolutions and the time ‎since the last revolution into speed in miles per hour. The speed is ‎computed by converting revolutions since the last report, rev_diff, ‎and time since the last report, wheel_diff into rotations per minute ‎and then into miles per hour using some fancy unit conversion.‎

Download File

Copy Code
def _compute_speed(self, values, speed):
wheel_diff = values.last_wheel_event_time - self._previous_wheel
rev_diff = values.cumulative_wheel_revolutions - self._previous_revolutions
if wheel_diff:
# Rotations per minute is 60 times the amount of revolutions since
# the last update over the time since the last update
rpm = 60*(rev_diff/(wheel_diff/1024))
# We then mutiply it by the wheel's circumference and convert it to mph
speed = round((rpm * self.circumference) * (60/63360), 1)
if speed < 0:
speed = self._previous_speed
self._previous_speed = speed
self._previous_revolutions = values.cumulative_wheel_revolutions
self._speed_failed = 0
else:
self._speed_failed += 1
if self._speed_failed >= 3:
speed = 0
self._previous_wheel = values.last_wheel_event_time

return speed

_compute_cadence‎

This works in a very similar way to _compute_speed, except since ‎cadence is measured in rotations per minute, it doesn't need as ‎much unit conversion. Also similar to the previous function, most of ‎the function is related to preventing undesired results being ‎displayed.‎

Download File

Copy Code
def _compute_cadence(self, values, cadence):
crank_diff = values.last_crank_event_time - self._previous_crank
crank_rev_diff = values.cumulative_crank_revolutions-self._previous_rev

if crank_rev_diff:
# Rotations per minute is 60 times the amount of revolutions since the
# last update over the time since the last update
cadence = round(60*(crank_rev_diff/(crank_diff/1024)), 1)
if cadence < 0:
cadence = self._previous_cadence
self._previous_cadence = cadence
self._previous_rev = values.cumulative_crank_revolutions
self._cadence_failed = 0
else:
self._cadence_failed += 1
if self._cadence_failed >= 3:
cadence = 0
self._previous_crank = values.last_crank_event_time

return cadence

Read speed and cadence

Reads speed and cadence. Will set speed and/or cadence to 0 if they ‎haven't changed in around the last 2 seconds. Also sets them to their ‎previous values if it ‎receives None in svc.measurement_values. Additionally, it will ‎truncate speed and cadence to ensure they don't fill up their labels.‎

Download File

Copy Code
def read_s_and_c(self):
"""
Reads data from the speed and cadence sensor
"""
speed = self._previous_speed
cadence = self._previous_cadence
for conn, svc in zip(self.cyc_connections, self.cyc_services):
if not conn.connected:
speed = cadence = 0
continue
values = svc.measurement_values
if not values:
if self._cadence_failed >= 3 or self._speed_failed >= 3:
if self._cadence_failed > 3:
cadence = 0
if self._speed_failed > 3:
speed = 0
continue
if not values.last_wheel_event_time:
continue
speed = self._compute_speed(values, speed)
if not values.last_crank_event_time:
continue
cadence = self._compute_cadence(values, cadence)

if speed:
speed = str(speed)[:8]
if cadence:
cadence = str(cadence)[:8]

return speed, cadence

read_heart

Reads data from the heart rate monitor. Occasionally, it sends None ‎as the current heart rate, and in that case, we set the current heart ‎rate to the previous heart rate and return it. ‎

Download File

Copy Code
def read_heart(self):
"""
Reads date from the heart rate sensor
"""
measurement = self._hr_service.measurement_values
if measurement is None:
heart = self._previous_heart
else:
heart = measurement.heart_rate
self._previous_heart = measurement.heart_rate

if heart:
heart = str(heart)[:4]

return heart

read_ams

Gets data about the current playing track from AppleMediaServices. ‎Every 3 seconds, this switches from being the artist to being the ‎track title. If either of these is longer than 16 characters, it gets ‎truncated to prevent the label from filling up.‎

Download File

Copy Code
def read_ams(self):
"""
Reads data from AppleMediaServices
"""
current = time.time()
try:
if current - self.start > 3:
self.track_artist = not self.track_artist
self.start = time.time()
if self.track_artist:
data = self.ams.artist
if not self.track_artist:
data = self.ams.title
except (RuntimeError, UnicodeError):
data = None

if data:
data = data[:16] + (data[16:] and '..')

return data

_icon_maker

icon_maker is used to be able to create sprites in one line instead of ‎three.‎

Download File

Copy Code
def icon_maker(self, n, icon_x, icon_y):
"""
Generates icons as sprites
"""
sprite = displayio.TileGrid(self.sprite_sheet, pixel_shader=self.palette, width=1,
height=1, tile_width=40, tile_height=40, default_tile=n,
x=icon_x, y=icon_y)
return sprite

_label_maker‎

_label_maker makes creating labels slightly more readable.‎

Download File

Copy Code
def _label_maker(self, text, x, y, font=None):
"""
Generates labels
"""
if not font:
font = self.arial24
return label.Label(font=font, x=x, y=y, text=text, color=self.WHITE)

‎_get_y‎

This uses the number of enabled devices, num_enabled to determine ‎the placement of labels and sprites in the UI.‎

Download File

Copy Code
def _get_y(self):
"""
Helper function for setup_display. Gets the y values used for sprites and labels.
"""
enabled = self.num_enabled

if self.heart_enabled:
self._heart_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.speed_enabled:
self._speed_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.cadence_enabled:
self._cadence_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.ams_enabled:
self._ams_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1

setup_display

This creates all the UI elements that do not change. First it creates a ‎rectangle at the top of the screen to hold the heading, then it creates ‎the heading. Then it places the sprites from the sprite sheet at ‎locations that correspond with how many devices are enabled.‎

Download File

Copy Code
def setup_display(self):
"""
Prepares the display to show sensor values: Adds a header, a heading, and various sprites.
"""
self._get_y()
sprites = displayio.Group()

rect = Rect(0, 0, 240, 50, fill=self.PURPLE)
self.splash.append(rect)

heading = label.Label(font=self.arial24, x=55, y=25, text="Pyloton", color=self.YELLOW)
self.splash.append(heading)

if self.heart_enabled:
heart_sprite = self.icon_maker(0, 2, self._heart_y - 20)
sprites.append(heart_sprite)

if self.speed_enabled:
speed_sprite = self.icon_maker(1, 2, self._speed_y - 20)
sprites.append(speed_sprite)

if self.cadence_enabled:
cadence_sprite = self.icon_maker(2, 2, self._cadence_y - 20)
sprites.append(cadence_sprite)

if self.ams_enabled:
ams_sprite = self.icon_maker(3, 2, self._ams_y - 20)
sprites.append(ams_sprite)

self.splash.append(sprites)

self.display.show(self.splash)
while self.loading_group:
self.loading_group.pop()

update_display

update_display is used to display the most recent values from the ‎sensors on the screen. If it is running for the first time, it will create ‎the labels, and set _setup to True upon finishing. From then on, the ‎text of the labels is edited, which increases the refresh rate.‎

Download File

Copy Code
def update_display(self): #pylint: disable=too-many-branches
"""
Updates the display to display the most recent values
"""
if self.speed_enabled or self.cadence_enabled:
speed, cadence = self.read_s_and_c()

if self.heart_enabled:
heart = self.read_heart()
if not self._setup:
self._hr_label = self._label_maker('{} bpm'.format(heart), 50, self._heart_y)
self.splash.append(self._hr_label)
else:
self._hr_label.text = '{} bpm'.format(heart)

if self.speed_enabled:
if not self._setup:
self._sp_label = self._label_maker('{} mph'.format(speed), 50, self._speed_y)
self.splash.append(self._sp_label)
else:
self._sp_label.text = '{} mph'.format(speed)

if self.cadence_enabled:
if not self._setup:
self._cadence_label = self._label_maker('{} rpm'.format(cadence), 50,
self._cadence_y)
self.splash.append(self._cadence_label)
else:
self._cadence_label.text = '{} rpm'.format(cadence)

if self.ams_enabled:
ams = self.read_ams()
if not self._setup:
self._ams_label = self._label_maker('{}'.format(ams), 50, self._ams_y,
font=self.arial16)
self.splash.append(self._ams_label)
else:
self._ams_label.text = '{}'.format(ams)

self._setup = True

ams_remote

ams_remote uses the built-in buttons and capacitive touch pads to ‎control media playing on an Apple device using AppleMediaServices. ‎Button 'A' is on the left and button 'B' is on the right. The three ‎capacitive touch pads are at the bottom and are labeled 0, 1, and 2.‎

Download File

Copy Code
def ams_remote(self):
"""
Allows the 2 buttons and 3 capacitive touch pads in the CLUE to function as a media remote.
"""
try:
# Capacitive touch pad marked 0 goes to the previous track
if self.clue.touch_0:
self.ams.previous_track()
time.sleep(0.25)

# Capacitive touch pad marked 1 toggles pause/play
if self.clue.touch_1:
self.ams.toggle_play_pause()
time.sleep(0.25)

# Capacitive touch pad marked 2 advances to the next track
if self.clue.touch_2:
self.ams.next_track()
time.sleep(0.25)

# If button B (on the right) is pressed, it increases the volume
if 'B' in self.clue.were_pressed:
self.ams.volume_up()
time.sleep(0.1)

# If button A (on the left) is pressed, the volume decreases
if 'A' in self.clue.were_pressed:
self.ams.volume_down()
time.sleep(0.1)
except (RuntimeError, UnsupportedCommand, AttributeError):
return

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2020 Eva Herrada for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
A library for completing the Pyloton bike computer learn guide utilizing the Adafruit CLUE.
"""

import time
import adafruit_ble
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.advertising.standard import SolicitServicesAdvertisement
import board
import digitalio
import displayio
import adafruit_imageload
from adafruit_ble_cycling_speed_and_cadence import CyclingSpeedAndCadenceService
from adafruit_ble_heart_rate import HeartRateService
from adafruit_bitmap_font import bitmap_font
from adafruit_display_shapes.rect import Rect
from adafruit_display_text import label
from adafruit_ble_apple_media import AppleMediaService
from adafruit_ble_apple_media import UnsupportedCommand
import gamepad
import touchio

class Clue:
"""
A very minimal version of the CLUE library.
The library requires the use of many sensor-specific
libraries this project doesn't use, and they were
taking up a lot of RAM.
"""
def __init__(self):
self._i2c = board.I2C()
self._touches = [board.D0, board.D1, board.D2]
self._touch_threshold_adjustment = 0
self._a = digitalio.DigitalInOut(board.BUTTON_A)
self._a.switch_to_input(pull=digitalio.Pull.UP)
self._b = digitalio.DigitalInOut(board.BUTTON_B)
self._b.switch_to_input(pull=digitalio.Pull.UP)
self._gamepad = gamepad.GamePad(self._a, self._b)

@property
def were_pressed(self):
"""
Returns a set of buttons that have been pressed since the last time were_pressed was run.
"""
ret = set()
pressed = self._gamepad.get_pressed()
for button, mask in (('A', 0x01), ('B', 0x02)):
if mask & pressed:
ret.add(button)
return ret

@property
def button_a(self):
"""``True`` when Button A is pressed. ``False`` if not."""
return not self._a.value

@property
def button_b(self):
"""``True`` when Button B is pressed. ``False`` if not."""
return not self._b.value

def _touch(self, i):
if not isinstance(self._touches[i], touchio.TouchIn):
self._touches[i] = touchio.TouchIn(self._touches[i])
self._touches[i].threshold += self._touch_threshold_adjustment
return self._touches[i].value

@property
def touch_0(self):
"""
Returns True when capacitive touchpad 0 is currently being pressed.
"""
return self._touch(0)

@property
def touch_1(self):
"""
Returns True when capacitive touchpad 1 is currently being pressed.
"""
return self._touch(1)

@property
def touch_2(self):
"""
Returns True when capacitive touchpad 2 is currently being pressed.
"""
return self._touch(2)


class Pyloton:
"""
Contains the various functions necessary for doing the Pyloton learn guide.
"""
#pylint: disable=too-many-instance-attributes

YELLOW = 0xFCFF00
PURPLE = 0x64337E
WHITE = 0xFFFFFF

clue = Clue()

def __init__(self, ble, display, circ, heart=True, speed=True, cad=True, ams=True, debug=False): #pylint: disable=too-many-arguments
self.debug = debug
self.ble = ble
self.display = display
self.circumference = circ

self.heart_enabled = heart
self.speed_enabled = speed
self.cadence_enabled = cad
self.ams_enabled = ams
self.hr_connection = None

self.num_enabled = heart + speed + cad + ams

self._previous_wheel = 0
self._previous_crank = 0
self._previous_revolutions = 0
self._previous_rev = 0
self._previous_speed = 0
self._previous_cadence = 0
self._previous_heart = 0
self._speed_failed = 0
self._cadence_failed = 0
self._setup = 0
self._hr_label = None
self._sp_label = None
self._cadence_label = None
self._ams_label = None
self._hr_service = None
self._heart_y = None
self._speed_y = None
self._cadence_y = None
self._ams_y = None
self.ams = None
self.cyc_connections = None
self.cyc_services = None
self.track_artist = True

self.start = time.time()

self.splash = displayio.Group()
self.loading_group = displayio.Group()

self._load_fonts()

self.sprite_sheet, self.palette = adafruit_imageload.load("/sprite_sheet.bmp",
bitmap=displayio.Bitmap,
palette=displayio.Palette)

self.text_group = displayio.Group()
self.status = label.Label(font=self.arial12, x=10, y=200,
text='', color=self.YELLOW)
self.status1 = label.Label(font=self.arial12, x=10, y=220,
text='', color=self.YELLOW)

self.text_group.append(self.status)
self.text_group.append(self.status1)


def show_splash(self):
"""
Shows the loading screen
"""
if self.debug:
return

blinka_bitmap = "blinka-pyloton.bmp"

# Compatible with CircuitPython 6 & 7
with open(blinka_bitmap, 'rb') as bitmap_file:
bitmap1 = displayio.OnDiskBitmap(bitmap_file)
tile_grid = displayio.TileGrid(bitmap1, pixel_shader=getattr(bitmap1, 'pixel_shader', displayio.ColorConverter()))
self.loading_group.append(tile_grid)
self.display.show(self.loading_group)
status_heading = label.Label(font=self.arial16, x=80, y=175,
text="Status", color=self.YELLOW)
rect = Rect(0, 165, 240, 75, fill=self.PURPLE)
self.loading_group.append(rect)
self.loading_group.append(status_heading)

# # Compatible with CircuitPython 7+
# bitmap1 = displayio.OnDiskBitmap(blinka_bitmap)
# tile_grid = displayio.TileGrid(bitmap1, pixel_shader=bitmap1.pixel_shader)
# self.loading_group.append(tile_grid)
# self.display.show(self.loading_group)
# status_heading = label.Label(font=self.arial16, x=80, y=175,
# text="Status", color=self.YELLOW)
# rect = Rect(0, 165, 240, 75, fill=self.PURPLE)
# self.loading_group.append(rect)
# self.loading_group.append(status_heading)

def _load_fonts(self):
"""
Loads fonts
"""
self.arial12 = bitmap_font.load_font("/fonts/Arial-12.bdf")
self.arial16 = bitmap_font.load_font("/fonts/Arial-16.bdf")
self.arial24 = bitmap_font.load_font("/fonts/Arial-Bold-24.bdf")

glyphs = b'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-!,. "\'?!'
self.arial12.load_glyphs(glyphs)
self.arial16.load_glyphs(glyphs)
self.arial24.load_glyphs(glyphs)


def _status_update(self, message):
"""
Displays status updates
"""
if self.debug:
print(message)
return
if self.text_group not in self.loading_group:
self.loading_group.append(self.text_group)
self.status.text = message[:25]
self.status1.text = message[25:50]


def timeout(self):
"""
Displays Timeout on screen when pyloton has been searching for a sensor for too long
"""
self._status_update("Pyloton: Timeout")
time.sleep(3)


def heart_connect(self):
"""
Connects to heart rate sensor
"""
self._status_update("Heart Rate: Scanning...")
for adv in self.ble.start_scan(ProvideServicesAdvertisement, timeout=5):
if HeartRateService in adv.services:
self._status_update("Heart Rate: Found an advertisement")
self.hr_connection = self.ble.connect(adv)
self._status_update("Heart Rate: Connected")
break
self.ble.stop_scan()
if self.hr_connection:
self._hr_service = self.hr_connection[HeartRateService]
return self.hr_connection


@staticmethod
def _has_timed_out(start, timeout):
if time.time() - start >= timeout:
return True
return False

def ams_connect(self, start=time.time(), timeout=30):
"""
Connect to an Apple device using the ble_apple_media library
"""
self._status_update("AppleMediaService: Connect your phone now")
radio = adafruit_ble.BLERadio()
a = SolicitServicesAdvertisement()
a.solicited_services.append(AppleMediaService)
radio.start_advertising(a)

while not radio.connected and not self._has_timed_out(start, timeout):
pass

self._status_update("AppleMediaService: Connected")
for connection in radio.connections:
if not connection.paired:
connection.pair()
self._status_update("AppleMediaService: Paired")
self.ams = connection[AppleMediaService]

return radio


def speed_cadence_connect(self):
"""
Connects to speed and cadence sensor
"""
self._status_update("Speed and Cadence: Scanning...")
# Save advertisements, indexed by address
advs = {}
for adv in self.ble.start_scan(ProvideServicesAdvertisement, timeout=5):
if CyclingSpeedAndCadenceService in adv.services:
self._status_update("Speed and Cadence: Found an advertisement")
# Save advertisement. Overwrite duplicates from same address (device).
advs[adv.address] = adv

self.ble.stop_scan()
self._status_update("Speed and Cadence: Stopped scanning")
if not advs:
# Nothing found. Go back and keep looking.
return []

# Connect to all available CSC sensors.
self.cyc_connections = []
for adv in advs.values():
self.cyc_connections.append(self.ble.connect(adv))
self._status_update("Speed and Cadence: Connected {}".format(len(self.cyc_connections)))

self.cyc_services = []
for conn in self.cyc_connections:
self.cyc_services.append(conn[CyclingSpeedAndCadenceService])
self._status_update("Pyloton: Finishing up...")

return self.cyc_connections


def _compute_speed(self, values, speed):
wheel_diff = values.last_wheel_event_time - self._previous_wheel
rev_diff = values.cumulative_wheel_revolutions - self._previous_revolutions
if wheel_diff:
# Rotations per minute is 60 times the amount of revolutions since
# the last update over the time since the last update
rpm = 60*(rev_diff/(wheel_diff/1024))
# We then mutiply it by the wheel's circumference and convert it to mph
speed = round((rpm * self.circumference) * (60/63360), 1)
if speed < 0:
speed = self._previous_speed
self._previous_speed = speed
self._previous_revolutions = values.cumulative_wheel_revolutions
self._speed_failed = 0
else:
self._speed_failed += 1
if self._speed_failed >= 3:
speed = 0
self._previous_wheel = values.last_wheel_event_time

return speed


def _compute_cadence(self, values, cadence):
crank_diff = values.last_crank_event_time - self._previous_crank
crank_rev_diff = values.cumulative_crank_revolutions-self._previous_rev

if crank_rev_diff:
# Rotations per minute is 60 times the amount of revolutions since the
# last update over the time since the last update
cadence = round(60*(crank_rev_diff/(crank_diff/1024)), 1)
if cadence < 0:
cadence = self._previous_cadence
self._previous_cadence = cadence
self._previous_rev = values.cumulative_crank_revolutions
self._cadence_failed = 0
else:
self._cadence_failed += 1
if self._cadence_failed >= 3:
cadence = 0
self._previous_crank = values.last_crank_event_time

return cadence


def read_s_and_c(self):
"""
Reads data from the speed and cadence sensor
"""
speed = self._previous_speed
cadence = self._previous_cadence
for conn, svc in zip(self.cyc_connections, self.cyc_services):
if not conn.connected:
speed = cadence = 0
continue
values = svc.measurement_values
if not values:
if self._cadence_failed >= 3 or self._speed_failed >= 3:
if self._cadence_failed > 3:
cadence = 0
if self._speed_failed > 3:
speed = 0
continue
if not values.last_wheel_event_time:
continue
speed = self._compute_speed(values, speed)
if not values.last_crank_event_time:
continue
cadence = self._compute_cadence(values, cadence)

if speed:
speed = str(speed)[:8]
if cadence:
cadence = str(cadence)[:8]

return speed, cadence


def read_heart(self):
"""
Reads date from the heart rate sensor
"""
measurement = self._hr_service.measurement_values
if measurement is None:
heart = self._previous_heart
else:
heart = measurement.heart_rate
self._previous_heart = measurement.heart_rate

if heart:
heart = str(heart)[:4]

return heart


def read_ams(self):
"""
Reads data from AppleMediaServices
"""
current = time.time()
try:
if current - self.start > 3:
self.track_artist = not self.track_artist
self.start = time.time()
if self.track_artist:
data = self.ams.artist
if not self.track_artist:
data = self.ams.title
except (RuntimeError, UnicodeError):
data = None

if data:
data = data[:16] + (data[16:] and '..')

return data


def icon_maker(self, n, icon_x, icon_y):
"""
Generates icons as sprites
"""
sprite = displayio.TileGrid(self.sprite_sheet, pixel_shader=self.palette, width=1,
height=1, tile_width=40, tile_height=40, default_tile=n,
x=icon_x, y=icon_y)
return sprite


def _label_maker(self, text, x, y, font=None):
"""
Generates labels
"""
if not font:
font = self.arial24
return label.Label(font=font, x=x, y=y, text=text, color=self.WHITE)


def _get_y(self):
"""
Helper function for setup_display. Gets the y values used for sprites and labels.
"""
enabled = self.num_enabled

if self.heart_enabled:
self._heart_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.speed_enabled:
self._speed_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.cadence_enabled:
self._cadence_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1
if self.ams_enabled:
self._ams_y = 45*(self.num_enabled - enabled) + 75
enabled -= 1


def setup_display(self):
"""
Prepares the display to show sensor values: Adds a header, a heading, and various sprites.
"""
self._get_y()
sprites = displayio.Group()

rect = Rect(0, 0, 240, 50, fill=self.PURPLE)
self.splash.append(rect)

heading = label.Label(font=self.arial24, x=55, y=25, text="Pyloton", color=self.YELLOW)
self.splash.append(heading)

if self.heart_enabled:
heart_sprite = self.icon_maker(0, 2, self._heart_y - 20)
sprites.append(heart_sprite)

if self.speed_enabled:
speed_sprite = self.icon_maker(1, 2, self._speed_y - 20)
sprites.append(speed_sprite)

if self.cadence_enabled:
cadence_sprite = self.icon_maker(2, 2, self._cadence_y - 20)
sprites.append(cadence_sprite)

if self.ams_enabled:
ams_sprite = self.icon_maker(3, 2, self._ams_y - 20)
sprites.append(ams_sprite)

self.splash.append(sprites)

self.display.show(self.splash)
while self.loading_group:
self.loading_group.pop()


def update_display(self): #pylint: disable=too-many-branches
"""
Updates the display to display the most recent values
"""
if self.speed_enabled or self.cadence_enabled:
speed, cadence = self.read_s_and_c()

if self.heart_enabled:
heart = self.read_heart()
if not self._setup:
self._hr_label = self._label_maker('{} bpm'.format(heart), 50, self._heart_y) # 75
self.splash.append(self._hr_label)
else:
self._hr_label.text = '{} bpm'.format(heart)

if self.speed_enabled:
if not self._setup:
self._sp_label = self._label_maker('{} mph'.format(speed), 50, self._speed_y) # 120
self.splash.append(self._sp_label)
else:
self._sp_label.text = '{} mph'.format(speed)

if self.cadence_enabled:
if not self._setup:
self._cadence_label = self._label_maker('{} rpm'.format(cadence), 50,
self._cadence_y)
self.splash.append(self._cadence_label)
else:
self._cadence_label.text = '{} rpm'.format(cadence)

if self.ams_enabled:
ams = self.read_ams()
if not self._setup:
self._ams_label = self._label_maker('{}'.format(ams), 50, self._ams_y,
font=self.arial16)
self.splash.append(self._ams_label)
else:
self._ams_label.text = '{}'.format(ams)

self._setup = True


def ams_remote(self):
"""
Allows the 2 buttons and 3 capacitive touch pads in the CLUE to function as a media remote.
"""
try:
# Capacitive touch pad marked 0 goes to the previous track
if self.clue.touch_0:
self.ams.previous_track()
time.sleep(0.25)

# Capacitive touch pad marked 1 toggles pause/play
if self.clue.touch_1:
self.ams.toggle_play_pause()
time.sleep(0.25)

# Capacitive touch pad marked 2 advances to the next track
if self.clue.touch_2:
self.ams.next_track()
time.sleep(0.25)

# If button B (on the right) is pressed, it increases the volume
if self.clue.button_b:
self.ams.volume_up()
time.sleep(0.1)

# If button A (on the left) is pressed, the volume decreases
if self.clue.button_a:
self.ams.volume_down()
time.sleep(0.1)
except (RuntimeError, UnsupportedCommand, AttributeError):
return

View on GitHub

3D Printing‎

First, you'll need to 3D print the Pyloton case and lid, as well as a ‎mounting bracket or wrist mounting bracelet if you want to use that ‎version.‎

For bike mounting, there are a couple of options of bracket size, ‎depending on the diameter of your handlebars.‎

mounts_20

‎3D Printed Parts‎

STL files for 3D printing are oriented to print "as-is" on FDM style ‎machines. Original design source may be downloaded using the ‎links below.‎

  • clue-frame.stl
  • clue-bike-case-btns.stl
  • clue-bike-lid-tripod.stl or clue-bike-lid-5m.stl
  • clue-bike-bracket-tri-*.stl or clue-bike-bracket-m5-*.stl
    • ‎24mm or 31mm diameter‎

Slicing Parts

Supports recommended around the lanyard ears and slide switch. ‎Slice with setting for PLA material.‎

The parts were sliced using CURA using the slice settings below.‎

  • PLA filament 210c extruder
  • ‎0.2-layer height
  • ‎10% gyroid infill
  • ‎60mm/s print speed
  • ‎70c heated bed‎

slicing_21

Supports - Place supports under the slide switch walls as shown

STLs CLUE bike mount (31mm or 24mm bracket M5) and (1/4-20 case ‎and brackets)‎

Edit CLUE bike mount (24mm diameter 1/4 - 20 mount)‎

Edit bracket (31.8mm M5 screws)‎

Edit bracket (24mm diameter M5)‎

Edit M5 lid

For full instructions on how to assemble and customize flexible ‎watch bands, check out the wearable case ‎guide: https://learn.adafruit.com/clue-case/3d-printing

Full instructions for Wearable case

wearable_22

Edit CLUE wearable case

CLUE STLs

In order to power the Pyloton, we'll need to create a switching ‎battery extension, as shown in this excellent guide, DIY On/Off JST ‎Switch Adapter.

Use the Pyloton

use_23

Time to use the Pyloton and get your quantified self out on a bike ‎ride!‎

Make sure you've got your heart rate monitor turned on and ‎strapped to your wrist or chest and give your bike crank and wheel a ‎little wiggle to make sure the sensors are awake.‎

Then, turn on the Pyloton. It'll begin looking for connections to the ‎iOS device, heart rate monitor, and cadence & speed sensors. The ‎first one it will attempt is the connection to the iOS device.‎

turn_24

iOS Connection

With Bluetooth turned on on the iOS device, you will see ‎the Pyloton pop up in the Other Devices list. Go ahead and click it.‎

Note: the device may have a name like CIRCUITPYadde as seen here. ‎Pick the device and it will make pop up the Bluetooth Pairing ‎Request dialog box. Click Pair.‎

The Pyloton will show up in your My Devices list as Connected, and ‎it will automatically connect in the future.‎

display_25

AMS Connection

The Pyloton will display that it is making a connection to the Apple ‎Media Service.‎

ams_26

Sensor Connections

Next, the Pyloton will look for and connect to a heart rate monitor, ‎followed by a cadence & speed sensor. There is no pairing/bonding ‎step for these, the Pyloton will simply connect to the first ones it ‎encounters that are advertising those services.‎

sensor_27

sensor_28

sensor_29

sensor_30

Display

The Pyloton will now display:‎

  • Heart rate
  • Cycling Speed
  • Cycling Cadence
  • Song title and artist track info

display_31

Get Track Info

Go ahead and launch a media player app, such as Spotify.‎

You'll see that the Pyloton displays the track title and artist ‎‎(alternating every few seconds), in the track info box.‎

Change the song on your iOS device, and it will update on the ‎Pyloton.‎

Send Media Control Commands

You can also send the player commands from the Pyloton.‎

Press the B button (on the right) of the CLUE to lower the volume or ‎the A button to increase it.‎

The three capacitive touch sensors on the edge connector of the ‎CLUE are used for more media controls:‎

  • Pad 0 selects the previous track
  • Pad 1 pauses/plays the current track
  • Pad 2 selects the next track

track_32

track_33

 

制造商零件编号 4500
CLUE NRF52840 EXPRESS
Adafruit Industries LLC
¥365.88
Details
制造商零件编号 1131
JST-PH BATTERY EXT CABLE
Adafruit Industries LLC
¥15.87
Details
制造商零件编号 2464
SWIVEL-HEAD PAN TILT SHOE MOUNT
Adafruit Industries LLC
¥42.59
Details
Add all DigiKey Parts to Cart
TechForum

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.

Visit TechForum