From cee480f9c86bedd7940c6729ce564464981a1b71 Mon Sep 17 00:00:00 2001 From: KasparByrne <103092736+KasparByrne@users.noreply.github.com> Date: Mon, 19 Aug 2024 08:16:26 +1000 Subject: [PATCH 1/3] Added Logging & Initial merged Wahoo code -Modified the standard MQTT client to use logging rather than printing. -Added the initial experimental code for a merged Wahoo driver code - with intention to merge functionality from driver code such as kickr_climb_and_smart_trainer, fan, heart_rate_sensor, cadence_sensor. -This new driver code will reorganise and standardise expected functionality of the original driver code into a set of classes to be handled by a heirarchy of controller/manager classes. --- Drivers/lib/mqtt_client.py | 24 +- Drivers/pico_remote/readme.md | 25 + Drivers/wahoo_device_controller/README.md | 87 +++ .../mqtt_custom_client.py | 21 + .../wahoo_controller.py | 649 ++++++++++++++++++ .../wahoo_controller_starter.py | 49 ++ .../wahoo_device_controller/wahoo_device.py | 428 ++++++++++++ 7 files changed, 1279 insertions(+), 4 deletions(-) create mode 100644 Drivers/wahoo_device_controller/README.md create mode 100644 Drivers/wahoo_device_controller/mqtt_custom_client.py create mode 100644 Drivers/wahoo_device_controller/wahoo_controller.py create mode 100644 Drivers/wahoo_device_controller/wahoo_controller_starter.py create mode 100644 Drivers/wahoo_device_controller/wahoo_device.py diff --git a/Drivers/lib/mqtt_client.py b/Drivers/lib/mqtt_client.py index f62b7213..5b0fc72c 100755 --- a/Drivers/lib/mqtt_client.py +++ b/Drivers/lib/mqtt_client.py @@ -3,6 +3,22 @@ import time import paho.mqtt.client as paho from paho import mqtt +import logging + +# setup logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +logger_formatter = logging.Formatter('%(levelname)s:%(name)s:%(message)s') + +logger_file_handler = logging.FileHandler('wahoo.log') # TODO: setup a logging folder and write all logging files to that folder +logger_file_handler.setFormatter(logger_formatter) + +logger_stream_handler = logging.StreamHandler() # this will print all logs to the terminal also + +logger.addHandler(logger_file_handler) +logger.addHandler(logger_stream_handler) # this is a MQTT client that is able to publish to and subscribe from MQTT topics in HiveMQ Cloud class MQTTClient: @@ -54,19 +70,19 @@ def loop_start(self): # setting callbacks for different events to see if it works, print the message etc. def on_connect(self, client, userdata, flags, rc, properties=None): - print("CONNACK received with code %s." % rc) + logger.info("CONNACK received with code %s." % rc) # with this callback you can see if your publish was successful def on_publish(self, client, userdata, mid, properties=None): - print("[MQTT message published] mid: " + str(mid)) + logger.info("[MQTT message published] mid: " + str(mid)) # print which topic was subscribed to def on_subscribe(self, client, userdata, mid, granted_qos, properties=None): - print("Subscribed: " + str(mid) + " " + str(granted_qos)) + logger.info("Subscribed: " + str(mid) + " " + str(granted_qos)) # print message, useful for checking if it was successful def on_message(self, client, userdata, msg): - print(msg.topic + " " + str(msg.qos) + " " + str(msg.payload)) + logger.info(msg.topic + " " + str(msg.qos) + " " + str(msg.payload)) def on_disconnect(self, client, userdata,rc=0): self.client.logging.debug(f"Disconnected result code: {str(rc)}") diff --git a/Drivers/pico_remote/readme.md b/Drivers/pico_remote/readme.md index e69de29b..91f94506 100644 --- a/Drivers/pico_remote/readme.md +++ b/Drivers/pico_remote/readme.md @@ -0,0 +1,25 @@ +# Description: +This device interacts with the wahoo kickr and climbr via the Raspberry Pi 000001, located in the IoT lab, building M.102. +Its intention is to manipulate the hardware in real-time without the need for running or accessing third-party software such as the mobile app, VR, or other interfaces. +The purpose of this is that it allows users to simply use the bike with a simple interface. + +# How to Run: +- Kickr script (/iot/scripts/./start_kickr.sh) must be executed and successful connection between Pi and Kickr must be established for this device to work as intended. +- Turn on remote device by pushing the white button on the power regulator on the top of the breadboard +- BT module should be flashing red while waiting for pairing. +- Navigate to iot/Drivers/pico_remote and execute the script via 'python3 pico_bt_handler.py' to run the handler. +- successful connection is determined by the HC-06 module turning into a solid red light. +- Upon successful connection between the HC-06 Bluetooth module and the Raspberry pi, you may now interact with the hardware via the push buttons: + +### Note: You must press and hold the selected button in order to influence hardware. + +# Buttons: +### Button 1: increase resistance +### Button 2: decrease resistance +### Button 3: increase incline +### Button 4: decrease incline + +![Screenshot 2023-09-24 142627](https://github.com/redbackoperations/iot/assets/69894063/d3f90db2-0b68-41e7-b8c1-3ca8d65c8ad4) +![WIN_20230924_12_03_43_Pro](https://github.com/redbackoperations/iot/assets/69894063/0cd708ff-146f-48b0-ac11-f858ef215387) +![WIN_20230924_12_03_08_Pro](https://github.com/redbackoperations/iot/assets/69894063/91f63b5b-432b-4208-a054-40ffb96bd527) + diff --git a/Drivers/wahoo_device_controller/README.md b/Drivers/wahoo_device_controller/README.md new file mode 100644 index 00000000..b744af48 --- /dev/null +++ b/Drivers/wahoo_device_controller/README.md @@ -0,0 +1,87 @@ +# BLE Control for Wahoo Kickr Smart Trainer and Wahoo Kickr Climb + +### This driver is capable of controlling both resistance and inclination for Wahoo Kickr Smart Trainer and Wahoo Kickr Climb devices. + +--- + +## Prerequisites + +1. This driver is tested in a **Linux OS environment** - Raspberry Pi 4 Model B. It doesn't work in MacOS due to some missing packages. Most probably, it won't work for Windows either. So a **Linux OS environment** is needed to run this driver. + +2. Please follow the [gatt-python](https://github.com/getsenic/gatt-python) module's README to install all of the necessary dependencies. + +3. Ensure you have `systemctl`, `bluez`, `gattctl`, `bluetoothctl` and `python3-dbus` installed already. + +4. Please also make sure you have installed [paho-mqtt](https://github.com/eclipse/paho.mqtt.python) module: `pip install paho-mqtt`. + +5. To test out this driver, you will need a MQTT broker setup at [HiveMQ Cloud](https://www.hivemq.com/mqtt-cloud-broker/) + +6. A BLE fitness hardware device with Fitness Machine Service (FTMS) support is also needed to test this driver. Alternatively, you can create a virtual BLE device using a BLE development tool, such as [LightBlue App](https://apps.apple.com/us/app/lightblue/id557428110). + +## Usage + +1. Ensure the fitness hardware device has been turned on and waiting for BLE pairing. + +2. In a Linux terminal, run either `sudo gattctl --discover` or `./Drivers/lib/ble_devices_scan.py` (under the `iot` Git repo) to scan BLE devices, and find out the exact MAC address for the fitness hardware device you're going to interact with. + +3. Under the `iot` Git repo, run the following command with proper BLE and MQTT connection arguments to initiate the driver for incline and resistance control: + +``` +./Drivers/kickr_climb_and_smart_trainer/incline_and_resistance_control.py --mac_address "THE_WAHOO_KICKR_PRODUCT_BLUETOOTH_MAC_ADDRESS" --broker_address="HIVEMQ_CLOUD_MQTT_BROKER_ADDRESS_HERE" --username="HIVEMQ_CLOUD_USERNAME_HERE" --password="HIVEMQ_CLOUD_PASSWORD_HERE" --resistance_command_topic=bike/000001/resistance/control --incline_command_topic=bike/000001/incline/control --resistance_report_topic=bike/000001/resistance --incline_report_topic=bike/000001/incline +``` + +4. If the BLE and MQTT connections are built correctly, you should now see some logs as the following: + +``` +Connecting to the BLE device... +[2b:80:03:12:bf:dd] Resolved services +[2b:80:03:12:bf:dd] Service [0000fab0-0000-1000-8000-00805f9b34fb] +... +CONNACK received with code Success. +Subscribed: 1 [, ] +... +``` + +5. Connect to your MQTT broker using either a MQTT CLI tool or a MQTT UI tool. And subscribe to the MQTT command and command report topics, such as `bike/000001/resistance/control` and `bike/000001/resistance`. + +6. You can now publish a MQTT message to the corresponding command topic (e.g., `bike/000001/resistance/control` or `bike/000001/incline/control`) with a `text/plain` payload like: `-10` for incline or `100` for resistance. + +7. From the terminal log, you will see a MQTT command message has been received and it's sent to the BLE FTMS control point for resistance control or the custom Characteristic control point for incline control to assign the new value: + +``` +... +[MQTT message received for Topic: 'bike/000001/incline', QOS: 0] -10 +Trying to set a new inclination value: -10 +Requesting FTMS control... +A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb +A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb +A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb +A new inclination has been set successfully: -10 +... +``` + +8. From the MQTT broker interface you've connected before, since a new resistance/incline value has been set, you will see a new command report with the newly assigned value coming up to the subscribed command report topic, such as `bike/000001/resistance` or `bike/000001/incline`. + +## Helpful Resources + +The official Bluetooth specification docs relating to BLE Fitness devices can be found in the followings: + +- https://www.thisisant.com/assets/resources/Datasheets/D00001699_-_GFIT_User_Guide_and_Specification_Document_v2.0.pdf +- https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0/ +- https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-6/ + +Here're some helpful docs relating to MQTT module usages in Python: + +- http://www.steves-internet-guide.com/loop-python-mqtt-client/ +- https://github.com/eclipse/paho.mqtt.python + +Here're some helpful resources about 1) how BLE devices to be connected via GATT protocol and 2) how to control Bluetooth FTMS features, such as resistance and inclination: + +- https://www.youtube.com/watch?v=AokDN6r4iz8 +- https://github.com/Berg0162/simcline/tree/master/Wahoo%20Kickr/ +- https://stormotion.io/blog/how-to-integrate-ble-fitness-devices-into-app/ +- https://docs.microsoft.com/en-us/windows/uwp/devices-sensors/gatt-client#perform-readwrite-operations-on-a-characteristic +- https://learn.adafruit.com/introduction-to-bluetooth-low-energy/gatt +- https://github.com/getsenic/gatt-python + +A full sample log about using this driver to interact with a [Yesoul Smart Bike](https://www.yesoul.net/bike/m1) can be found under this path: `/Drivers/kickr_climb_and_smart_trainer/sample_logs/ftms_service_and_characteristic_read_sample_1.log`. diff --git a/Drivers/wahoo_device_controller/mqtt_custom_client.py b/Drivers/wahoo_device_controller/mqtt_custom_client.py new file mode 100644 index 00000000..3e449feb --- /dev/null +++ b/Drivers/wahoo_device_controller/mqtt_custom_client.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +import re +import os +import sys + +root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(root_folder) + +from lib.mqtt_client import MQTTClient +from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX + +# define a custom MQTT Client to be able send BLE FTMS commands while receiving command messages from a MQTT command topic +class MQTTClientWithSendingFTMSCommands(MQTTClient): + def __init__(self, broker_address, username, password, device): + super().__init__(broker_address, username, password) + self.device = device + + def on_message(self, client, userdata, msg): + super().on_message(self,client,userdata, msg) + self.device.on_message(msg) \ No newline at end of file diff --git a/Drivers/wahoo_device_controller/wahoo_controller.py b/Drivers/wahoo_device_controller/wahoo_controller.py new file mode 100644 index 00000000..9021c60a --- /dev/null +++ b/Drivers/wahoo_device_controller/wahoo_controller.py @@ -0,0 +1,649 @@ +from pickle import TRUE +import re +import os +import sys +import gatt +import platform +import json +import time +from time import sleep +from mqtt_custom_client import MQTTClientWithSendingFTMSCommands +import threading +import logging + +root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(root_folder) + +from lib.ble_helper import convert_incline_to_op_value, service_or_characteristic_found, service_or_characteristic_found_full_match, decode_int_bytes, covert_negative_value_to_valid_bytes +from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX, FTMS_UUID, RESISTANCE_LEVEL_RANGE_UUID, INCLINATION_RANGE_UUID, FTMS_CONTROL_POINT_UUID, FTMS_REQUEST_CONTROL, FTMS_RESET, FTMS_SET_TARGET_RESISTANCE_LEVEL, INCLINE_REQUEST_CONTROL, INCLINE_CONTROL_OP_CODE, INCLINE_CONTROL_SERVICE_UUID, INCLINE_CONTROL_CHARACTERISTIC_UUID, INDOOR_BIKE_DATA_UUID, DEVICE_UNIT_NAMES + +""" +TODO design testing of changes for + - Test climb + +TODO complete first stage code for testing + - Complete WahooController essential code + 0 FTMS control point request + 0 FTMS reset [Check if this is necessary] + 0 services_resolved + - Complete WahooController and Climb compatibility + - Add thread LOCK to Climb + +TODO complete wahoo_device.py equivalence + - Resistance compatibility + +TODO merge other Wahoo devices + - Fan + - Add heart monitor data report to WahooData + +TODO next stage + - report errors from device clients upstream back to game + 0 just send the log message with time to a MQTT topic +""" + +# setup logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +logger_formatter = logging.Formatter('%(levelname)s:%(name)s:%(message)s') + +logger_file_handler = logging.FileHandler('wahoo.log') # TODO: setup a logging folder and write all logging files to that folder +logger_file_handler.setFormatter(logger_formatter) + +logger_stream_handler = logging.StreamHandler() # this will print all logs to the terminal also + +logger.addHandler(logger_file_handler) +logger.addHandler(logger_stream_handler) + +# a sleep time to wait for a characteristic.writevalue() action to be completed +WRITEVALUE_WAIT_TIME = 0.1 # TODO: If this doesn't work well, it needs to change this short sleep mechainism to a async process mechainism for sending consequetive BLE commands (eg., threading control) + +# define Control Point response type constants +WRITE_SUCCESS, WRITE_FAIL, NOTIFICATION_SUCCESS, NOTIFICATION_FAIL = range(4) + +# ======== GATT Interface Class ======== + +class GATTInterface(gatt.Device): + """This class should handle GATT functionality, including: + * Connection + * Response logging + """ + + def __init__(self, mac_address, manager, args, managed=True): + super().__init__(mac_address, manager, managed) + + # Fitness Machine Service device & control point + self.ftms = None + self.ftms_control_point = None + + # bike data characteristic + self.indoor_bike_data = None + + # CLI parser arguments + self.args = args + + def set_service_or_characteristic(self, service_or_characteristic): + + # find services & characteristics of the KICKR trainer + if service_or_characteristic_found(FTMS_UUID, service_or_characteristic.uuid): + self.ftms = service_or_characteristic + elif service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, service_or_characteristic.uuid): + self.ftms_control_point = service_or_characteristic + elif service_or_characteristic_found(INDOOR_BIKE_DATA_UUID, service_or_characteristic.uuid): + self.indoor_bike_data = service_or_characteristic + self.indoor_bike_data.enable_notification() + + # ====== Log connection & characteristic update ====== + # TODO: check if those supers do anything - fairly certain they are virtual methods + + def connect_succeeded(self): + super().connect_succeeded() + logger.info("[%s] Connected" % (self.mac_address)) + + def connect_failed(self, error): + super().connect_failed(error) + logger.debug("[%s] Connection failed: %s" % (self.mac_address, str(error))) + sys.exit() + + def disconnect_succeeded(self): + super().disconnect_succeeded() + logger.info("[%s] Disconnected" % (self.mac_address)) + + def characteristic_value_updated(self, characteristic, value): + logger.debug(f"The updated value for {characteristic.uuid} is:", value) + + # ====== Control Point Response Methods ====== + + def control_point_response(self, characteristic, response_type: int, error = None): + """Handle responses from indicated control points + + virutal method to be implemented by child""" + pass + + def characteristic_write_value_succeeded(self, characteristic): + logger.debug(f"WRITE SUCCESS : {characteristic.uuid}") + self.control_point_response(characteristic,response_type=WRITE_SUCCESS) + + def characteristic_write_value_failed(self, characteristic, error): + logger.debug(f"WRITE FAIL : {characteristic.uuid} : {str(error)}") + self.control_point_response(characteristic,response_type=WRITE_FAIL,error=error) + + def characteristic_enable_notification_succeeded(self, characteristic): + logger.debug(f"NOTIFICATION ENABLED : {characteristic.uuid}") + self.control_point_response(characteristic,response_type=NOTIFICATION_SUCCESS) + + def characteristic_enable_notification_failed(self, characteristic, error): + logger.debug(f"NOTIFICATION ENABLING FAILED : {characteristic.uuid} : {str(error)}") + self.control_point_response(characteristic,response_type=NOTIFICATION_FAIL,error=error) + + # ====== Request & Resolve control point + def services_resolved(self): + super().services_resolved() + +# ======== Wahoo Controller Class ======== + +class WahooController(GATTInterface): + """This sub-class should extend the GATTInterface class to also handle: + * MQTT [or any alternative networking protocols] + * Individual Wahoo devices + * Pulling data from devices""" + + def __init__(self, mac_address, manager, args, managed=True): + super().__init__(mac_address, manager, args, managed) + + # CLI parser arguments + self.args = args + + # Wahoo devices + self.climber = Climber(self,args) + self.resistance = Resistance(self,args) + self.fan = HeadwindFan(self,args) + + self.devices = [self.climber, self.resistance, self.fan] + + # Wahoo data handler + self.wahoo_data = WahooData(self,args) + + # ===== MQTT methods ===== + + def setup_mqtt_client(self): + self.mqtt_client = MQTTClientWithSendingFTMSCommands(self.args.broker_address, self.args.username, self.args.password, self) + self.mqtt_client.setup_mqtt_client() + + def on_message(self, msg): + """Run when a subscribed MQTT topic publishes""" + for device in self.devices: + device.on_message(msg) + + def subscribe(self, topic: str, SubscribeOptions: int = 0): + """Subscribe to a MQTT topic""" + self.mqtt_client.subscribe((topic,SubscribeOptions)) + + def publish(self, topic: str, payload): + """Publish to a MQTT topic""" + self.mqtt_client.publish(topic, payload) + + def mqtt_data_report_payload(self, device_type, value): + """Create a standardised payload for MQTT publishing""" + # TODO: add more json data payload whenever needed later + return json.dumps({"value": value, "unitName": DEVICE_UNIT_NAMES[device_type], "timestamp": time.time(), "metadata": { "deviceName": platform.node() } }) + + # ===== GATT for devices ===== + + def set_service_or_characteristic(self, service_or_characteristic): + super().set_service_or_characteristic(self, service_or_characteristic) + + # TODO: add flow control by returning True if the service/characteristic was matched and then terminating the search + for device in self.devices: + device.set_service_or_characteristic(service_or_characteristic) + + def characteristic_value_updated(self, characteristic, value): + super().set_service_or_characteristic(self,characteristic,value) + + if characteristic == self.indoor_bike_data: + self.wahoo_data.process_data(value) + + def control_point_response(self, characteristic, response_type: int, error = None): + """forward responses and their types to devices""" + + # TODO: handle responses from own control point + + # forward responses + for device in self.devices: + device.control_point_response(characteristic, response_type, error) + + # ===== Resolve control point & services/characteristics + + # this is the main process that will be run all time after manager.run() is called + # FIXME: despite what is stated above - this process is not looped - it runs only once in testing. + # Maybe it reruns it until all services & characteristics have been resolved? + # TODO: Double check all the below to ensure it is working and clean it up a bit + def services_resolved(self): + super().services_resolved() + + print("[%s] Resolved services" % (self.mac_address)) + for service in self.services: + print("[%s]\tService [%s]" % (self.mac_address, service.uuid)) + self.set_service_or_characteristic(service) + + for characteristic in service.characteristics: + self.set_service_or_characteristic(characteristic) + print("[%s]\t\tCharacteristic [%s]" % (self.mac_address, characteristic.uuid)) + print("The characteristic value is: ", characteristic.read_value()) + + # TODO: check if it is necessary to filter by service - if so rewrite set_service_or_characteristic to take a service arg + """ + if self.ftms == service: + # set for FTMS control point for resistance control + self.set_service_or_characteristic(characteristic) + if self.custom_incline_service == service: + # set for custom control point for incline control + self.set_service_or_characteristic(characteristic) + """ + + # continue if FTMS service is found from the BLE device + if self.ftms and self.indoor_bike_data: + + # request control point + self.ftms_control_point.write_value(bytearray([FTMS_REQUEST_CONTROL])) + + # start looping MQTT messages + self.mqtt_client.loop_start() + + + + +# ======== Wahoo Device Classes ======== + +class WahooDevice: + """A virtual class for Wahoo devices""" + + def __init__(self, controller: WahooController, args): + + # device controller + self.controller = controller + + # CLI parser arguments + self.args = args + + # command topic + self.command_topic = None + + # report topic + self.report_topic = None + + # device control point service & characteristic + self.control_point_service = None + self.control_point = None + + # constants for threading + self._TIMEOUT = 10 + + def set_control_point(self, service_or_characteristic): + """Set UUID of passed service/characteristic if it is a required control point service/characteristic + + To be implemented by subclass""" + pass + + def on_message(self, msg): + """Receive subscribed MQTT messages + + To be implemented by subclass""" + pass + + def control_point_response(self, characteristic, response_type: int, error = None): + """Handle responses from control point. Responses should be used for flow control of threading. + + To be implemented by subclass""" + pass + +class Climber(WahooDevice): + """Handles control of the KICKR Climb""" + + def __init__(self, controller: WahooController, args): + super().__init__(self,controller,args) + + # device variable + self._incline = 0 + self._new_incline = None + + # command topic + self.command_topic = self.args.incline_command_topic + self.controller.subscribe(self.command_topic) + + # report topic + self.report_topic = self.args.incline_report_topic + + # threading + self.terminate_write = False + self.write_timeout_count = 0 + self.write_thread = None + + def set_control_point(self, service_or_characteristic): + + # find the custom KICKR climb control point service & characteristic + if service_or_characteristic_found_full_match(INCLINE_CONTROL_SERVICE_UUID, service_or_characteristic.uuid): + self.control_point_service = service_or_characteristic + elif service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, service_or_characteristic.uuid): + self.control_point = service_or_characteristic + # TODO: check whether this requires that the FTMS control point has already successfully been requested control of + self.control_point.enable_notification() + + def on_message(self, msg): + """Receive MQTT messages""" + + # check if it is the incline topic + if bool(re.search("/incline", msg.topic, re.IGNORECASE)): + + # convert, validate, and write the new value + value = str(msg.payload, 'utf-8') + if bool(re.search("[-+]?\d+$", value)): + value = float(value) + if INCLINE_MIN <= value <= INCLINE_MAX and value % 0.5 == 0: + self.incline = value + else: + logger.debug(f'INCLINE MQTT COMMAND FAIL : value must be in range 19 to -10 with 0.5 resolution : {value}') + else: + logger.debug(f'INCLINE MQTT COMMAND FAIL : non-numeric value sent : {value}') + + def report(self): + """Report a successful incline write""" + payload = self.controller.mqtt_data_report_payload('incline',self._incline) + self.controller.publish(self.report_topic,payload) + + def control_point_response(self, characteristic, response_type: int, error = None): + """Handle responses from the control point""" + + # if the response is not from the relevant control point then return + if characteristic.uuid != self.control_point.uuid: return + + # on successful write terminate the thread and update the internal incline value + if response_type == WRITE_SUCCESS: + # TODO: LOCK this property at start of method + self.terminate_write = True + self._incline = self._new_incline + self.report() + logger.debug(f'INCLINE WRITE SUCCESS: {self._incline}') + + # on failed write try writing again until timeout + elif response_type == WRITE_FAIL: + self.write_timeout_count += 1 + self.write_thread.start() + logger.debug(f'INCLINE WRITE FAILED: {self._new_incline}') + + # TODO: Add check that we are notifying the correct characteristic - handling error responses + # on successful enabling of notification on control point log + elif response_type == NOTIFICATION_SUCCESS: + logger.debug('INCLINE NOTIFICATION SUCCESS') + + # on fail to enable notification on control point try again + elif response_type == NOTIFICATION_FAIL: + logger.debug('INCLINE NOTIFICATION FAILED') + self.control_point.enable_notification() + + @property + def incline(self,val): + """Try to write a new incline value until timeout, success or new value to be written""" + + # define the new value internally + self._new_incline = val + + # terminate any current threads + # TODO: LOCK this property at start of method + self.terminate_write = True + self.write_thread.join() + + # setup & start new thread + self.terminate_write = False + self.write_timeout_count = 0 + self.write_thread = threading.Thread(name='write_new_incline',target=self.write_new_incline,args=(self._new_incline)) + self.write_thread.start() + + def write_new_incline(self): + """Attempt to write the new incline value until successful or forced to terminate""" + + # write the new value until termination or timeout + if not (self.terminate_write and self.write_timeout_count >= self._TIMEOUT): + self.control_point.write_value(bytearray([INCLINE_CONTROL_OP_CODE] + convert_incline_to_op_value(self._new_incline))) + + +class Resistance(WahooDevice): + """Handles control of the Resistance aspect of the KICKR Smart Trainer""" + + def __init__(self, controller: WahooController, args): + super().__init__(self,controller,args) + + # device variable + self._resistance = 0 + + # command topic + self.command_topic = self.args.resistance_command_topic + self.controller.subscribe(self.command_topic) + + # report topic + self.report_topic = self.args.resistance_report_topic + + def set_control_point(self, service_or_characteristic): + + # setup aliases for the FTMS control point service & characteristic + self.control_point_service = self.controller.ftms + self.control_point = self.controller.ftms_control_point + + def on_message(self, msg): + """Receive MQTT messages""" + pass + + # ========================================= + + # TODO: add response reactions to Resistance class + """def characteristic_write_value_succeeded(self, characteristic): + + # set new resistance or inclination and notify to MQTT if the async write value action is succeeded + if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): + if self.new_resistance is not None: + self.set_new_resistance()""" + + # ========================================== + +class HeadwindFan(WahooDevice): + """Handles control of the KICKR Headwind Smart Bluetooth Fan""" + + def __init__(self, controller: WahooController, args): + super().__init__(self,controller,args) + + # device variable + self._fan_power = 0 + + def on_message(self, msg): + """Receive MQTT messages""" + pass + +""" +Class to handle data from Wahoo devices. All data is streamed through the KICKR smart trainer so best handled by a single class. +All code copied over from old code so it could use a clean up. +A lot of the processed data is not relevant - most are for Wahoo devices we do not have - but someone put the effort into developing +those bits so might as well keep it in case we use it in the future. +I think things could be cleaned up a lot on the pull_value method but need better undertsanding of handling bit data. +""" + +class WahooData: + + def __init__(self, controller: WahooController, args): + + # device controller + self.controller = controller + + # CLI parser arguments + self.args = args + + # track if bike is new data + self.idle = False + + # data flags + """Many of these are for Wahoo devices that we are not using/do not have""" + self.flag_instantaneous_speed = None + self.flag_average_speed = None + self.flag_instantaneous_cadence = None + self.flag_average_cadence = None + self.flag_total_distance = None + self.flag_resistance_level = None + self.flag_instantaneous_power = None + self.flag_average_power = None + self.flag_expended_energy = None + self.flag_heart_rate = None + self.flag_metabolic_equivalent = None + self.flag_elapsed_time = None + self.flag_remaining_time = None + + # data values + self.instantaneous_speed = None + self.average_speed = None + self.instantaneous_cadence = None + self.average_cadence = None + self.total_distance = None + self.resistance_level = None + self.instantaneous_power = None + self.average_power = None + self.expended_energy_total = None + self.expended_energy_per_hour = None + self.expended_energy_per_minute = None + self.heart_rate = None + self.metabolic_equivalent = None + self.elapsed_time = None + self.remaining_time = None + + def process_data(self, value): + self.reported_data(value) + self.pull_value(value) + self.publish_data() + + def reported_data(self, value): + """Check the received bit data for which data was reported""" + self.flag_instantaneous_speed = not((value[0] & 1) >> 0) + self.flag_average_speed = (value[0] & 2) >> 1 + self.flag_instantaneous_cadence = (value[0] & 4) >> 2 + self.flag_average_cadence = (value[0] & 8) >> 3 + self.flag_total_distance = (value[0] & 16) >> 4 + self.flag_resistance_level = (value[0] & 32) >> 5 + self.flag_instantaneous_power = (value[0] & 64) >> 6 + self.flag_average_power = (value[0] & 128) >> 7 + self.flag_expended_energy = (value[1] & 1) >> 0 + self.flag_heart_rate = (value[1] & 2) >> 1 + self.flag_metabolic_equivalent = (value[1] & 4) >> 2 + self.flag_elapsed_time = (value[1] & 8) >> 3 + self.flag_remaining_time = (value[1] & 16) >> 4 + + def pull_value(self, value): + """Get the reported data from the bit data""" + offset = 2 + + if self.flag_instantaneous_speed: + self.instantaneous_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 + offset += 2 + logger.info(f"Instantaneous Speed: {self.instantaneous_speed} m/s") + + if self.flag_average_speed: + self.average_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 + offset += 2 + logger.info(f"Average Speed: {self.average_speed} m/s") + + if self.flag_instantaneous_cadence: + self.instantaneous_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 + offset += 2 + logger.info(f"Instantaneous Cadence: {self.instantaneous_cadence} rpm") + + if self.flag_average_cadence: + self.average_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 + offset += 2 + logger.info(f"Average Cadence: {self.average_cadence} rpm") + + if self.flag_total_distance: + self.total_distance = int((value[offset+2] << 16) + (value[offset+1] << 8) + value[offset]) + offset += 3 + logger.info(f"Total Distance: {self.total_distance} m") + + if self.flag_resistance_level: + self.resistance_level = int((value[offset+1] << 8) + value[offset]) + offset += 2 + logger.info(f"Resistance Level: {self.resistance_level}") + + if self.flag_instantaneous_power: + self.instantaneous_power = int((value[offset+1] << 8) + value[offset]) + offset += 2 + logger.info(f"Instantaneous Power: {self.instantaneous_power} W") + + if self.flag_average_power: + self.average_power = int((value[offset+1] << 8) + value[offset]) + offset += 2 + logger.info(f"Average Power: {self.average_power} W") + + if self.flag_expended_energy: + expended_energy_total = int((value[offset+1] << 8) + value[offset]) + offset += 2 + if expended_energy_total != 0xFFFF: + self.expended_energy_total = expended_energy_total + logger.info(f"Expended Energy: {self.expended_energy_total} kCal total") + + expended_energy_per_hour = int((value[offset+1] << 8) + value[offset]) + offset += 2 + if expended_energy_per_hour != 0xFFFF: + self.expended_energy_per_hour = expended_energy_per_hour + logger.info(f"Expended Energy: {self.expended_energy_per_hour} kCal/hour") + + expended_energy_per_minute = int(value[offset]) + offset += 1 + if expended_energy_per_minute != 0xFF: + self.expended_energy_per_minute = expended_energy_per_minute + logger.info(f"Expended Energy: {self.expended_energy_per_minute} kCal/min") + + if self.flag_heart_rate: + self.heart_rate = int(value[offset]) + offset += 1 + logger.info(f"Heart Rate: {self.heart_rate} bpm") + + if self.flag_metabolic_equivalent: + self.metabolic_equivalent = float(value[offset]) / 10.0 + offset += 1 + logger.info(f"Metabolic Equivalent: {self.metabolic_equivalent} METS") + + if self.flag_elapsed_time: + self.elapsed_time = int((value[offset+1] << 8) + value[offset]) + offset += 2 + logger.info(f"Elapsed Time: {self.elapsed_time} seconds") + + if self.flag_remaining_time: + self.remaining_time = int((value[offset+1] << 8) + value[offset]) + offset += 2 + logger.info(f"Remaining Time: {self.remaining_time} seconds") + + if offset != len(value): + logger.error("ERROR: Payload was not parsed correctly") + return + + def publish_data(self): + """Publish if data or log that there was no relevant data""" + + if self.instantaneous_speed > 0: + self.idle = False + self.publish() + else: + if self.idle: + logger.info('Bike currently idle, no data published') + else: + self.publish() + self.idle = True + + def publish(self): + """Publish data""" + + if self.flag_instantaneous_speed: + self.controller.publish(self.args.speed_report_topic, self.controller.mqtt_data_report_payload('speed', self.instantaneous_speed)) + if self.flag_instantaneous_cadence: + self.controller.mqtt_client.publish(self.args.cadence_report_topic, self.controller.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) + if self.flag_instantaneous_power: + self.controller.mqtt_client.publish(self.args.power_report_topic, self.controller.mqtt_data_report_payload('power', self.instantaneous_power)) + + + + diff --git a/Drivers/wahoo_device_controller/wahoo_controller_starter.py b/Drivers/wahoo_device_controller/wahoo_controller_starter.py new file mode 100644 index 00000000..6ce17965 --- /dev/null +++ b/Drivers/wahoo_device_controller/wahoo_controller_starter.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import os +import sys +import gatt +from argparse import ArgumentParser +from wahoo_controller import WahooController + +root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(root_folder) + +from lib.constants import BIKE_01_INCLINE_COMMAND, BIKE_01_RESISTANCE_COMMAND, BIKE_01_INCLINE_REPORT, BIKE_01_RESISTANCE_REPORT, BIKE_01_SPEED_REPORT, BIKE_01_CADENCE_REPORT, BIKE_01_POWER_REPORT + +# define CLI parse arguments +parser = ArgumentParser(description="Wahoo Kickr Incline and Resistance Control") + +# BLE connection params +parser.add_argument('--mac_address', dest='mac_address', type=str, help="The Wahoo Kickr BLE Device's unique mac address") + +# HiveMQ connection params +parser.add_argument('--broker_address', dest='broker_address', type=str, help='The MQTT broker address getting from HiveMQ Cloud') +parser.add_argument('--username', dest='username', type=str, help='HiveMQ Cloud username') +parser.add_argument('--password', dest='password', type=str, help='HiveMQ Cloud password') + +parser.add_argument('--incline_command_topic', dest='incline_command_topic', type=str, help='a MQTT topic that will send incline or resistance control commands to this driver', default=BIKE_01_INCLINE_COMMAND) +parser.add_argument('--incline_report_topic', dest='incline_report_topic', type=str, help='a MQTT topic that will receieve the current incline or resistance levels data from this driver', default=BIKE_01_INCLINE_REPORT) +parser.add_argument('--resistance_command_topic', dest='resistance_command_topic', type=str, help='a MQTT topic that will send incline or resistance control commands to this driver', default=BIKE_01_RESISTANCE_COMMAND) +parser.add_argument('--resistance_report_topic', dest='resistance_report_topic', type=str, help='a MQTT topic that will receieve the current incline or resistance levels data from this driver', default=BIKE_01_RESISTANCE_REPORT) +parser.add_argument('--speed_report_topic', dest='speed_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous speed data in m/s from this driver', default=BIKE_01_SPEED_REPORT) +parser.add_argument('--cadence_report_topic', dest='cadence_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous cadence data in rpm from this driver', default=BIKE_01_CADENCE_REPORT) +parser.add_argument('--power_report_topic', dest='power_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous power data in W from this driver', default=BIKE_01_POWER_REPORT) + +args = parser.parse_args() + +print("Connecting to the BLE device...") +manager = gatt.DeviceManager(adapter_name='hci0') + +# initiate and connect a WahooDevice with a given BLE mac_address +device = WahooController(manager=manager, mac_address=args.mac_address, args=args) +device.connect() + +try: + print("Running the device manager now...") + # run the device manager in the main thread forever + manager.run() +except KeyboardInterrupt: + print ('Exit the program.') + manager.stop() + sys.exit(0) diff --git a/Drivers/wahoo_device_controller/wahoo_device.py b/Drivers/wahoo_device_controller/wahoo_device.py new file mode 100644 index 00000000..76dad0dd --- /dev/null +++ b/Drivers/wahoo_device_controller/wahoo_device.py @@ -0,0 +1,428 @@ +from pickle import TRUE +import re +import os +from sqlite3 import Timestamp +import sys +import gatt +import platform +import json +import time +from time import sleep +from mqtt_custom_client import MQTTClientWithSendingFTMSCommands +import threading +import logging + +root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(root_folder) + +from lib.ble_helper import convert_incline_to_op_value, service_or_characteristic_found, service_or_characteristic_found_full_match, decode_int_bytes, covert_negative_value_to_valid_bytes +from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX, FTMS_UUID, RESISTANCE_LEVEL_RANGE_UUID, INCLINATION_RANGE_UUID, FTMS_CONTROL_POINT_UUID, FTMS_REQUEST_CONTROL, FTMS_RESET, FTMS_SET_TARGET_RESISTANCE_LEVEL, INCLINE_REQUEST_CONTROL, INCLINE_CONTROL_OP_CODE, INCLINE_CONTROL_SERVICE_UUID, INCLINE_CONTROL_CHARACTERISTIC_UUID, INDOOR_BIKE_DATA_UUID, DEVICE_UNIT_NAMES + +# a sleep time to wait for a characteristic.writevalue() action to be completed +WRITEVALUE_WAIT_TIME = 0.1 # TODO: If this doesn't work well, it needs to change this short sleep mechainism to a async process mechainism for sending consequetive BLE commands (eg., threading control) + +# Wahoo Device Interface +""" +WARNING: This code was directly copied from `kickr_climb_and_smart_trainer/wahoo_device.py` and then modified - so some unexpected artifacts may be present. + +This class should act as an interface between the game communication and the individual Wahoo devices. +It should: +* Establish communication with the bike. +* Initialise the device controllers. +* Establish communication with the game. +* Instigate reading device data. +* Instigate writing device commands. +* Listen for game publications. +* Publish device data. +* Modularly structured: + 0 The communication methods should be segregated from their applications. + 0 Reason: Basically we want to be able to switch from MQTT easily - so if a controller returns data from the bike to the interface, then the interface will call a method to push that data to the game or mobile app. + Code using MQTT, CoAP or any other communication method we select to use should only appear in this final method during this process or similiar processes. +""" + +class WahooDevice(gatt.Device): + def __init__(self, mac_address, manager, args, managed=True): + super().__init__(mac_address, manager, managed) + + # define the initial FTMS Service and the corresponding Characteristics + self.ftms = None + self.inclination_range = None + self.resistance_level_range = None + self.ftms_control_point = None + + # define the initial Incline Service and the corresponding Characteristics + self.custom_incline_service = None + self.custom_incline_characteristic = None + + # define the initial resistance and inclination values + self.resistance = 0 + self.inclination = 0 + self.new_resistance = None + self.new_inclination = None + + # define the Characteristics for Indoor Bike Data (reporting speed, cadence and power) + self.indoor_bike_data = None + + # CLI parser arguments + self.args = args + + # Zero count + self.zero_count = 0 + + # setup MQTT connection + self.setup_mqtt_connection() + + # ===== Controller Specific Methods ===== + + # ===== MQTT methods ===== + + def setup_mqtt_connection(self): + self.mqtt_client = MQTTClientWithSendingFTMSCommands(self.args.broker_address, self.args.username, self.args.password, self) + self.mqtt_client.setup_mqtt_client() + + # subscribe to both resistance and incline command topics + self.mqtt_client.subscribe([(self.args.resistance_command_topic, 0), (self.args.incline_command_topic, 0)]) + + # ===== GATT methods ===== + + def set_service_or_characteristic(self, service_or_characteristic): + if service_or_characteristic_found(FTMS_UUID, service_or_characteristic.uuid): + self.ftms = service_or_characteristic + elif service_or_characteristic_found_full_match(INCLINE_CONTROL_SERVICE_UUID, service_or_characteristic.uuid): + self.custom_incline_service = service_or_characteristic + elif service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, service_or_characteristic.uuid): + self.custom_incline_characteristic = service_or_characteristic + elif service_or_characteristic_found(INCLINATION_RANGE_UUID, service_or_characteristic.uuid): + self.inclination_range = service_or_characteristic + elif service_or_characteristic_found(RESISTANCE_LEVEL_RANGE_UUID, service_or_characteristic.uuid): + self.resistance_level_range = service_or_characteristic + elif service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, service_or_characteristic.uuid): + self.ftms_control_point = service_or_characteristic + elif service_or_characteristic_found(INDOOR_BIKE_DATA_UUID, service_or_characteristic.uuid): + self.indoor_bike_data = service_or_characteristic + + def connect_succeeded(self): + super().connect_succeeded() + print("[%s] Connected" % (self.mac_address)) + + def connect_failed(self, error): + super().connect_failed(error) + print("[%s] Connection failed: %s" % (self.mac_address, str(error))) + sys.exit() + + def disconnect_succeeded(self): + super().disconnect_succeeded() + print("[%s] Disconnected" % (self.mac_address)) + + def ftms_request_control(self): + if self.ftms_control_point: + # request FTMS control + print("Requesting FTMS control...") + self.ftms_control_point.write_value(bytearray([FTMS_REQUEST_CONTROL])) + sleep(WRITEVALUE_WAIT_TIME) + + def ftms_reset_settings(self): + print("Initiating to reset control settings...") + if self.ftms_control_point: + # reset FTMS control settings + print("Resetting FTMS control settings...") + self.ftms_control_point.write_value(bytearray([FTMS_RESET])) + sleep(WRITEVALUE_WAIT_TIME) + + self.resistance = 0 + self.inclination = 0 + + # request resistance control + self.ftms_request_control() + # reset resistance down to 0% + self.ftms_set_target_resistance_level(self.resistance) + + # request incline control + self.custom_control_point_enable_notifications() + # reset incline to the flat level: 0% + self.custom_control_point_set_target_inclination(self.inclination) + + self.mqtt_client.publish(self.args.incline_report_topic, self.mqtt_data_report_payload('incline', self.inclination)) + self.mqtt_client.publish(self.args.resistance_report_topic, self.mqtt_data_report_payload('resistance', self.resistance)) + + # the resistance value is UINT8 type and unitless with a resolution of 0.1 + def ftms_set_target_resistance_level(self, new_resistance): + print(f"Trying to set a new resistance value: {new_resistance}") + + if self.ftms_control_point: + # initiate the action + self.ftms_control_point.write_value(bytearray([FTMS_SET_TARGET_RESISTANCE_LEVEL, new_resistance])) + self.new_resistance = new_resistance + sleep(WRITEVALUE_WAIT_TIME) + + def custom_control_point_enable_notifications(self): + if self.custom_incline_characteristic: + # has to do this step to be able to send incline value successfully + print("Enabling notifications for custom incline endpoint...") + self.custom_incline_characteristic.enable_notifications() + sleep(WRITEVALUE_WAIT_TIME) + + # the inclination value range is -10 to 19, in Percent with a resolution of 0.5% + def custom_control_point_set_target_inclination(self, new_inclination): + print(f"Trying to set a new inclination value: {new_inclination}") + + if self.custom_incline_characteristic: + # send the new inclination value to the custom characteristic + print("values are: ", convert_incline_to_op_value(new_inclination)) + self.custom_incline_characteristic.write_value(bytearray([INCLINE_CONTROL_OP_CODE] + convert_incline_to_op_value(new_inclination))) + #self.custom_incline_characteristic.write_value(bytearray([0x66, 0x6c, 0x07])) + self.new_inclination = new_inclination + sleep(WRITEVALUE_WAIT_TIME) + + def set_new_inclination(self): + self.inclination = self.new_inclination + self.new_inclination = None + print(f"A new inclination has been set successfully: {self.inclination}") + self.mqtt_client.publish(self.args.incline_report_topic, self.mqtt_data_report_payload('incline', self.inclination)) + + def set_new_resistance(self): + self.resistance = self.new_resistance + self.new_resistance = None + print(f"A new resistance has been set successfully: {self.resistance}") + self.mqtt_client.publish(self.args.resistance_report_topic, self.mqtt_data_report_payload('resistance', self.resistance)) + + def set_new_inclination_failed(self): + print(f"The new inclination has not been set successfully: {self.new_inclination}") + self.new_inclination = None + + def set_new_resistance_failed(self): + print(f"The new resistance has not been set successfully: {self.new_resistance}") + self.new_resistance = None + + def descriptor_read_value_failed(self, descriptor, error): + print('descriptor value read failed:', str(error)) + + def characteristic_value_updated(self, characteristic, value): + print(f"The updated value for {characteristic.uuid} is:", value) + + def characteristic_write_value_succeeded(self, characteristic): + print(f"A new value has been written to {characteristic.uuid}") + + # set new resistance or inclination and notify to MQTT if the async write value action is succeeded + if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): + if self.new_resistance is not None: + self.set_new_resistance() + if service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, characteristic.uuid): + if self.new_inclination is not None: + self.set_new_inclination() + + def characteristic_write_value_failed(self, characteristic, error): + print(f"A new value has not been written to {characteristic.uuid} successfully: {str(error)}") + + # clear the new resistance or inclination and notify to MQTT if the async write value action is failed + if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): + if self.new_resistance is not None: + self.set_new_resistance_failed() + if service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, characteristic.uuid): + if self.new_inclination is not None: + self.set_new_inclination_failed() + + def characteristic_enable_notification_succeeded(self, characteristic): + print(f"The {characteristic.uuid} has been enabled with notification!") + + def characteristic_enable_notification_failed(self, characteristic, error): + print(f"Cannot enable notification for {characteristic.uuid}: {str(error)}") + + # process the Indoor Bike Data + def process_indoor_bike_data(self, value): + flag_instantaneous_speed = not((value[0] & 1) >> 0) + flag_average_speed = (value[0] & 2) >> 1 + flag_instantaneous_cadence = (value[0] & 4) >> 2 + flag_average_cadence = (value[0] & 8) >> 3 + flag_total_distance = (value[0] & 16) >> 4 + flag_resistance_level = (value[0] & 32) >> 5 + flag_instantaneous_power = (value[0] & 64) >> 6 + flag_average_power = (value[0] & 128) >> 7 + flag_expended_energy = (value[1] & 1) >> 0 + flag_heart_rate = (value[1] & 2) >> 1 + flag_metabolic_equivalent = (value[1] & 4) >> 2 + flag_elapsed_time = (value[1] & 8) >> 3 + flag_remaining_time = (value[1] & 16) >> 4 + offset = 2 + + if flag_instantaneous_speed: + self.instantaneous_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 + offset += 2 + print(f"Instantaneous Speed: {self.instantaneous_speed} m/s") + + if flag_average_speed: + self.average_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 + offset += 2 + print(f"Average Speed: {self.average_speed} m/s") + + if flag_instantaneous_cadence: + self.instantaneous_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 + offset += 2 + print(f"Instantaneous Cadence: {self.instantaneous_cadence} rpm") + + if flag_average_cadence: + self.average_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 + offset += 2 + print(f"Average Cadence: {self.average_cadence} rpm") + + if flag_total_distance: + self.total_distance = int((value[offset+2] << 16) + (value[offset+1] << 8) + value[offset]) + offset += 3 + print(f"Total Distance: {self.total_distance} m") + + if flag_resistance_level: + self.resistance_level = int((value[offset+1] << 8) + value[offset]) + offset += 2 + print(f"Resistance Level: {self.resistance_level}") + + if flag_instantaneous_power: + self.instantaneous_power = int((value[offset+1] << 8) + value[offset]) + offset += 2 + print(f"Instantaneous Power: {self.instantaneous_power} W") + + if flag_average_power: + self.average_power = int((value[offset+1] << 8) + value[offset]) + offset += 2 + print(f"Average Power: {self.average_power} W") + + if flag_expended_energy: + expended_energy_total = int((value[offset+1] << 8) + value[offset]) + offset += 2 + if expended_energy_total != 0xFFFF: + self.expended_energy_total = expended_energy_total + print(f"Expended Energy: {self.expended_energy_total} kCal total") + + expended_energy_per_hour = int((value[offset+1] << 8) + value[offset]) + offset += 2 + if expended_energy_per_hour != 0xFFFF: + self.expended_energy_per_hour = expended_energy_per_hour + print(f"Expended Energy: {self.expended_energy_per_hour} kCal/hour") + + expended_energy_per_minute = int(value[offset]) + offset += 1 + if expended_energy_per_minute != 0xFF: + self.expended_energy_per_minute = expended_energy_per_minute + print(f"Expended Energy: {self.expended_energy_per_minute} kCal/min") + + if flag_heart_rate: + self.heart_rate = int(value[offset]) + offset += 1 + print(f"Heart Rate: {self.heart_rate} bpm") + + if flag_metabolic_equivalent: + self.metabolic_equivalent = float(value[offset]) / 10.0 + offset += 1 + print(f"Metabolic Equivalent: {self.metabolic_equivalent} METS") + + if flag_elapsed_time: + self.elapsed_time = int((value[offset+1] << 8) + value[offset]) + offset += 2 + print(f"Elapsed Time: {self.elapsed_time} seconds") + + if flag_remaining_time: + self.remaining_time = int((value[offset+1] << 8) + value[offset]) + offset += 2 + print(f"Remaining Time: {self.remaining_time} seconds") + + if offset != len(value): + print("ERROR: Payload was not parsed correctly") + return + + # The KICKR Trainer only reports instantaneous speed, cadence and power + # Publish them to MQTT topics if they were provided + if self.zero_count < 10: + if flag_instantaneous_speed: + self.mqtt_client.publish(self.args.speed_report_topic, self.mqtt_data_report_payload('speed', self.instantaneous_speed)) + if flag_instantaneous_cadence: + self.mqtt_client.publish(self.args.cadence_report_topic, self.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) + if flag_instantaneous_power: + self.mqtt_client.publish(self.args.power_report_topic, self.mqtt_data_report_payload('power', self.instantaneous_power)) + + if self.instantaneous_speed == 0: + self.zero_count += 1 + + print('Zero Count:', self.zero_count) + + elif self.zero_count >= 10 and self.instantaneous_speed > 0: + self.zero_count = 0 + if flag_instantaneous_speed: + self.mqtt_client.publish(self.args.speed_report_topic, self.mqtt_data_report_payload('speed', self.instantaneous_speed)) + if flag_instantaneous_cadence: + self.mqtt_client.publish(self.args.cadence_report_topic, self.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) + if flag_instantaneous_power: + self.mqtt_client.publish(self.args.power_report_topic, self.mqtt_data_report_payload('power', self.instantaneous_power)) + else: + print('Bike currently idle, no data publish to MQTT') + + def mqtt_data_report_payload(self, device_type, value): + # TODO: add more json data payload whenever needed later + return json.dumps({"value": value, "unitName": DEVICE_UNIT_NAMES[device_type], "timestamp": time.time(), "metadata": { "deviceName": platform.node() } }) + + # this will be called with updates from any characteristics which provide notifications (eg. Indoor Bike Data) + def characteristic_value_updated(self, characteristic, value): + if characteristic == self.indoor_bike_data: + self.process_indoor_bike_data(value) + + def on_message(self, msg): + # positve number for resistance value; positve/negative number for inclination value + value = str(msg.payload, 'utf-8') + print(f"[MQTT message received for Topic: '{msg.topic}', QOS: {str(msg.qos)}] ", str(value)) + + if bool(re.search("[-+]?\d+$", value)): + int_value = int(value) + if bool(re.search("/incline", msg.topic, re.IGNORECASE)): + if int_value > INCLINE_MAX or int_value < INCLINE_MIN: + message = f"Skip invalid incline value: {int_value} (the range has to be: {INCLINE_MIN}% - {INCLINE_MAX}%)" + print(message) + + self.publish(self.device.args.incline_report_topic, message) + else: + self.device.custom_control_point_set_target_inclination(int_value) + elif bool(re.search("/resistance", msg.topic, re.IGNORECASE)): + if int_value > RESISTANCE_MAX or int_value < RESISTANCE_MIN: + message = f"Skip invalid resistance value: {int_value}" + print(message) + + self.publish(self.device.args.resistance_report_topic, message) + else: + self.device.ftms_set_target_resistance_level(int_value) + else: + print("The command topic is not idetified.") + else: + print("Skip the invalid command payload.") + + # this is the main process that will be run all time after manager.run() is called + # FIXME: despite what is stated above - this process is not looped - it runs only once in testing. + def services_resolved(self): + super().services_resolved() + + print("[%s] Resolved services" % (self.mac_address)) + for service in self.services: + print("[%s]\tService [%s]" % (self.mac_address, service.uuid)) + self.set_service_or_characteristic(service) + + for characteristic in service.characteristics: + print("[%s]\t\tCharacteristic [%s]" % (self.mac_address, characteristic.uuid)) + print("The characteristic value is: ", characteristic.read_value()) + + if self.ftms == service: + # set for FTMS control point for resistance control + self.set_service_or_characteristic(characteristic) + if self.custom_incline_service == service: + # set for custom control point for incline control + self.set_service_or_characteristic(characteristic) + + # continue if FTMS service is found from the BLE device + if self.ftms and self.indoor_bike_data: + # read the supported resistance and inclination ranges here to set correct command values later + self.read_resistance_level_range() + self.read_inclination_range() + + # enable notifications for Indoor Bike Data + self.indoor_bike_data.enable_notifications() + + # reset control settings while initiating the BLE connection + self.ftms_reset_settings() + + # start looping MQTT messages + self.mqtt_client.loop_start() \ No newline at end of file From 33a458519cff5521231af983761ed23e55b2b78b Mon Sep 17 00:00:00 2001 From: KasparByrne <103092736+KasparByrne@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:09:20 +1000 Subject: [PATCH 2/3] Removed Credentials from Windows GUI & Fixed Depreciation Issue --- Drivers/Windows_GUI/smartbikegui_v5.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Drivers/Windows_GUI/smartbikegui_v5.py b/Drivers/Windows_GUI/smartbikegui_v5.py index 641f7798..4afdc158 100644 --- a/Drivers/Windows_GUI/smartbikegui_v5.py +++ b/Drivers/Windows_GUI/smartbikegui_v5.py @@ -4,6 +4,21 @@ import time import json from mqtt_client import MQTTClient +import warnings +import os + +# set this file's folder as the current working directory so you can use it while having the rest of the repo open +os.chdir(os.path.dirname(os.path.realpath(__file__))) + +# set MQTT credentials manually - DO NOT push credentials to repo +# TODO: create a file for the whole repo to reference for credentials +MQTT_HOST = '' +MQTT_USER = '' +MQTT_PASS = '' +if MQTT_HOST == '' or MQTT_USER == '' or MQTT_PASS == '': + raise ValueError('Explicitly set MQTT credentials in iot/Drivers/Windows_GUI/smartbikegui_v5.py') +else: + warnings.warn('Reminder: Do not push credentials to repo - remove credentials from iot/Drivers/Windows_GUI/smartbikegui_v5.py before push') # Global variables for GUI resistance_var = None @@ -147,8 +162,10 @@ def main(): try: global mqtt_client global deviceId - # Initialize MQTT client and subscribe to speed topic - mqtt_client = MQTTClient(('f5b2a345ee944354b5bf1263284d879e.s1.eu.hivemq.cloud'), ('redbackiotclient'), ('IoTClient@123')) + + # Initialize MQTT client and subscribe to speed topic + mqtt_client = MQTTClient((MQTT_HOST), (MQTT_USER), (MQTT_PASS)) + topic1 = f'bike/000001/speed' topic2 = f'bike/000001/power' topic3 = f'bike/000001/heartrate' @@ -186,7 +203,7 @@ def main(): # Background Image bg_image = Image.open("cycling_background_v2.jpg") - bg_image = bg_image.resize((screen_width, screen_height), Image.ANTIALIAS) + bg_image = bg_image.resize((screen_width, screen_height), Image.LANCZOS) bg_photo = ImageTk.PhotoImage(bg_image) bg_label = tk.Label(root, image=bg_photo) bg_label.place(x=0, y=0) From 6dc90203a42db8996b8a672622da814e07ec4112 Mon Sep 17 00:00:00 2001 From: KasparByrne Date: Wed, 4 Sep 2024 12:53:24 +1000 Subject: [PATCH 3/3] Revert "Added Logging & Initial merged Wahoo code" This reverts commit cee480f9c86bedd7940c6729ce564464981a1b71. --- Drivers/lib/mqtt_client.py | 24 +- Drivers/pico_remote/readme.md | 25 - Drivers/wahoo_device_controller/README.md | 87 --- .../mqtt_custom_client.py | 21 - .../wahoo_controller.py | 649 ------------------ .../wahoo_controller_starter.py | 49 -- .../wahoo_device_controller/wahoo_device.py | 428 ------------ 7 files changed, 4 insertions(+), 1279 deletions(-) delete mode 100644 Drivers/wahoo_device_controller/README.md delete mode 100644 Drivers/wahoo_device_controller/mqtt_custom_client.py delete mode 100644 Drivers/wahoo_device_controller/wahoo_controller.py delete mode 100644 Drivers/wahoo_device_controller/wahoo_controller_starter.py delete mode 100644 Drivers/wahoo_device_controller/wahoo_device.py diff --git a/Drivers/lib/mqtt_client.py b/Drivers/lib/mqtt_client.py index 5b0fc72c..f62b7213 100755 --- a/Drivers/lib/mqtt_client.py +++ b/Drivers/lib/mqtt_client.py @@ -3,22 +3,6 @@ import time import paho.mqtt.client as paho from paho import mqtt -import logging - -# setup logging - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -logger_formatter = logging.Formatter('%(levelname)s:%(name)s:%(message)s') - -logger_file_handler = logging.FileHandler('wahoo.log') # TODO: setup a logging folder and write all logging files to that folder -logger_file_handler.setFormatter(logger_formatter) - -logger_stream_handler = logging.StreamHandler() # this will print all logs to the terminal also - -logger.addHandler(logger_file_handler) -logger.addHandler(logger_stream_handler) # this is a MQTT client that is able to publish to and subscribe from MQTT topics in HiveMQ Cloud class MQTTClient: @@ -70,19 +54,19 @@ def loop_start(self): # setting callbacks for different events to see if it works, print the message etc. def on_connect(self, client, userdata, flags, rc, properties=None): - logger.info("CONNACK received with code %s." % rc) + print("CONNACK received with code %s." % rc) # with this callback you can see if your publish was successful def on_publish(self, client, userdata, mid, properties=None): - logger.info("[MQTT message published] mid: " + str(mid)) + print("[MQTT message published] mid: " + str(mid)) # print which topic was subscribed to def on_subscribe(self, client, userdata, mid, granted_qos, properties=None): - logger.info("Subscribed: " + str(mid) + " " + str(granted_qos)) + print("Subscribed: " + str(mid) + " " + str(granted_qos)) # print message, useful for checking if it was successful def on_message(self, client, userdata, msg): - logger.info(msg.topic + " " + str(msg.qos) + " " + str(msg.payload)) + print(msg.topic + " " + str(msg.qos) + " " + str(msg.payload)) def on_disconnect(self, client, userdata,rc=0): self.client.logging.debug(f"Disconnected result code: {str(rc)}") diff --git a/Drivers/pico_remote/readme.md b/Drivers/pico_remote/readme.md index 91f94506..e69de29b 100644 --- a/Drivers/pico_remote/readme.md +++ b/Drivers/pico_remote/readme.md @@ -1,25 +0,0 @@ -# Description: -This device interacts with the wahoo kickr and climbr via the Raspberry Pi 000001, located in the IoT lab, building M.102. -Its intention is to manipulate the hardware in real-time without the need for running or accessing third-party software such as the mobile app, VR, or other interfaces. -The purpose of this is that it allows users to simply use the bike with a simple interface. - -# How to Run: -- Kickr script (/iot/scripts/./start_kickr.sh) must be executed and successful connection between Pi and Kickr must be established for this device to work as intended. -- Turn on remote device by pushing the white button on the power regulator on the top of the breadboard -- BT module should be flashing red while waiting for pairing. -- Navigate to iot/Drivers/pico_remote and execute the script via 'python3 pico_bt_handler.py' to run the handler. -- successful connection is determined by the HC-06 module turning into a solid red light. -- Upon successful connection between the HC-06 Bluetooth module and the Raspberry pi, you may now interact with the hardware via the push buttons: - -### Note: You must press and hold the selected button in order to influence hardware. - -# Buttons: -### Button 1: increase resistance -### Button 2: decrease resistance -### Button 3: increase incline -### Button 4: decrease incline - -![Screenshot 2023-09-24 142627](https://github.com/redbackoperations/iot/assets/69894063/d3f90db2-0b68-41e7-b8c1-3ca8d65c8ad4) -![WIN_20230924_12_03_43_Pro](https://github.com/redbackoperations/iot/assets/69894063/0cd708ff-146f-48b0-ac11-f858ef215387) -![WIN_20230924_12_03_08_Pro](https://github.com/redbackoperations/iot/assets/69894063/91f63b5b-432b-4208-a054-40ffb96bd527) - diff --git a/Drivers/wahoo_device_controller/README.md b/Drivers/wahoo_device_controller/README.md deleted file mode 100644 index b744af48..00000000 --- a/Drivers/wahoo_device_controller/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# BLE Control for Wahoo Kickr Smart Trainer and Wahoo Kickr Climb - -### This driver is capable of controlling both resistance and inclination for Wahoo Kickr Smart Trainer and Wahoo Kickr Climb devices. - ---- - -## Prerequisites - -1. This driver is tested in a **Linux OS environment** - Raspberry Pi 4 Model B. It doesn't work in MacOS due to some missing packages. Most probably, it won't work for Windows either. So a **Linux OS environment** is needed to run this driver. - -2. Please follow the [gatt-python](https://github.com/getsenic/gatt-python) module's README to install all of the necessary dependencies. - -3. Ensure you have `systemctl`, `bluez`, `gattctl`, `bluetoothctl` and `python3-dbus` installed already. - -4. Please also make sure you have installed [paho-mqtt](https://github.com/eclipse/paho.mqtt.python) module: `pip install paho-mqtt`. - -5. To test out this driver, you will need a MQTT broker setup at [HiveMQ Cloud](https://www.hivemq.com/mqtt-cloud-broker/) - -6. A BLE fitness hardware device with Fitness Machine Service (FTMS) support is also needed to test this driver. Alternatively, you can create a virtual BLE device using a BLE development tool, such as [LightBlue App](https://apps.apple.com/us/app/lightblue/id557428110). - -## Usage - -1. Ensure the fitness hardware device has been turned on and waiting for BLE pairing. - -2. In a Linux terminal, run either `sudo gattctl --discover` or `./Drivers/lib/ble_devices_scan.py` (under the `iot` Git repo) to scan BLE devices, and find out the exact MAC address for the fitness hardware device you're going to interact with. - -3. Under the `iot` Git repo, run the following command with proper BLE and MQTT connection arguments to initiate the driver for incline and resistance control: - -``` -./Drivers/kickr_climb_and_smart_trainer/incline_and_resistance_control.py --mac_address "THE_WAHOO_KICKR_PRODUCT_BLUETOOTH_MAC_ADDRESS" --broker_address="HIVEMQ_CLOUD_MQTT_BROKER_ADDRESS_HERE" --username="HIVEMQ_CLOUD_USERNAME_HERE" --password="HIVEMQ_CLOUD_PASSWORD_HERE" --resistance_command_topic=bike/000001/resistance/control --incline_command_topic=bike/000001/incline/control --resistance_report_topic=bike/000001/resistance --incline_report_topic=bike/000001/incline -``` - -4. If the BLE and MQTT connections are built correctly, you should now see some logs as the following: - -``` -Connecting to the BLE device... -[2b:80:03:12:bf:dd] Resolved services -[2b:80:03:12:bf:dd] Service [0000fab0-0000-1000-8000-00805f9b34fb] -... -CONNACK received with code Success. -Subscribed: 1 [, ] -... -``` - -5. Connect to your MQTT broker using either a MQTT CLI tool or a MQTT UI tool. And subscribe to the MQTT command and command report topics, such as `bike/000001/resistance/control` and `bike/000001/resistance`. - -6. You can now publish a MQTT message to the corresponding command topic (e.g., `bike/000001/resistance/control` or `bike/000001/incline/control`) with a `text/plain` payload like: `-10` for incline or `100` for resistance. - -7. From the terminal log, you will see a MQTT command message has been received and it's sent to the BLE FTMS control point for resistance control or the custom Characteristic control point for incline control to assign the new value: - -``` -... -[MQTT message received for Topic: 'bike/000001/incline', QOS: 0] -10 -Trying to set a new inclination value: -10 -Requesting FTMS control... -A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb -A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb -A new value has been written to 00002ad9-0000-1000-8000-00805f9b34fb -A new inclination has been set successfully: -10 -... -``` - -8. From the MQTT broker interface you've connected before, since a new resistance/incline value has been set, you will see a new command report with the newly assigned value coming up to the subscribed command report topic, such as `bike/000001/resistance` or `bike/000001/incline`. - -## Helpful Resources - -The official Bluetooth specification docs relating to BLE Fitness devices can be found in the followings: - -- https://www.thisisant.com/assets/resources/Datasheets/D00001699_-_GFIT_User_Guide_and_Specification_Document_v2.0.pdf -- https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0/ -- https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-6/ - -Here're some helpful docs relating to MQTT module usages in Python: - -- http://www.steves-internet-guide.com/loop-python-mqtt-client/ -- https://github.com/eclipse/paho.mqtt.python - -Here're some helpful resources about 1) how BLE devices to be connected via GATT protocol and 2) how to control Bluetooth FTMS features, such as resistance and inclination: - -- https://www.youtube.com/watch?v=AokDN6r4iz8 -- https://github.com/Berg0162/simcline/tree/master/Wahoo%20Kickr/ -- https://stormotion.io/blog/how-to-integrate-ble-fitness-devices-into-app/ -- https://docs.microsoft.com/en-us/windows/uwp/devices-sensors/gatt-client#perform-readwrite-operations-on-a-characteristic -- https://learn.adafruit.com/introduction-to-bluetooth-low-energy/gatt -- https://github.com/getsenic/gatt-python - -A full sample log about using this driver to interact with a [Yesoul Smart Bike](https://www.yesoul.net/bike/m1) can be found under this path: `/Drivers/kickr_climb_and_smart_trainer/sample_logs/ftms_service_and_characteristic_read_sample_1.log`. diff --git a/Drivers/wahoo_device_controller/mqtt_custom_client.py b/Drivers/wahoo_device_controller/mqtt_custom_client.py deleted file mode 100644 index 3e449feb..00000000 --- a/Drivers/wahoo_device_controller/mqtt_custom_client.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 - -import re -import os -import sys - -root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.append(root_folder) - -from lib.mqtt_client import MQTTClient -from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX - -# define a custom MQTT Client to be able send BLE FTMS commands while receiving command messages from a MQTT command topic -class MQTTClientWithSendingFTMSCommands(MQTTClient): - def __init__(self, broker_address, username, password, device): - super().__init__(broker_address, username, password) - self.device = device - - def on_message(self, client, userdata, msg): - super().on_message(self,client,userdata, msg) - self.device.on_message(msg) \ No newline at end of file diff --git a/Drivers/wahoo_device_controller/wahoo_controller.py b/Drivers/wahoo_device_controller/wahoo_controller.py deleted file mode 100644 index 9021c60a..00000000 --- a/Drivers/wahoo_device_controller/wahoo_controller.py +++ /dev/null @@ -1,649 +0,0 @@ -from pickle import TRUE -import re -import os -import sys -import gatt -import platform -import json -import time -from time import sleep -from mqtt_custom_client import MQTTClientWithSendingFTMSCommands -import threading -import logging - -root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.append(root_folder) - -from lib.ble_helper import convert_incline_to_op_value, service_or_characteristic_found, service_or_characteristic_found_full_match, decode_int_bytes, covert_negative_value_to_valid_bytes -from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX, FTMS_UUID, RESISTANCE_LEVEL_RANGE_UUID, INCLINATION_RANGE_UUID, FTMS_CONTROL_POINT_UUID, FTMS_REQUEST_CONTROL, FTMS_RESET, FTMS_SET_TARGET_RESISTANCE_LEVEL, INCLINE_REQUEST_CONTROL, INCLINE_CONTROL_OP_CODE, INCLINE_CONTROL_SERVICE_UUID, INCLINE_CONTROL_CHARACTERISTIC_UUID, INDOOR_BIKE_DATA_UUID, DEVICE_UNIT_NAMES - -""" -TODO design testing of changes for - - Test climb - -TODO complete first stage code for testing - - Complete WahooController essential code - 0 FTMS control point request - 0 FTMS reset [Check if this is necessary] - 0 services_resolved - - Complete WahooController and Climb compatibility - - Add thread LOCK to Climb - -TODO complete wahoo_device.py equivalence - - Resistance compatibility - -TODO merge other Wahoo devices - - Fan - - Add heart monitor data report to WahooData - -TODO next stage - - report errors from device clients upstream back to game - 0 just send the log message with time to a MQTT topic -""" - -# setup logging - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -logger_formatter = logging.Formatter('%(levelname)s:%(name)s:%(message)s') - -logger_file_handler = logging.FileHandler('wahoo.log') # TODO: setup a logging folder and write all logging files to that folder -logger_file_handler.setFormatter(logger_formatter) - -logger_stream_handler = logging.StreamHandler() # this will print all logs to the terminal also - -logger.addHandler(logger_file_handler) -logger.addHandler(logger_stream_handler) - -# a sleep time to wait for a characteristic.writevalue() action to be completed -WRITEVALUE_WAIT_TIME = 0.1 # TODO: If this doesn't work well, it needs to change this short sleep mechainism to a async process mechainism for sending consequetive BLE commands (eg., threading control) - -# define Control Point response type constants -WRITE_SUCCESS, WRITE_FAIL, NOTIFICATION_SUCCESS, NOTIFICATION_FAIL = range(4) - -# ======== GATT Interface Class ======== - -class GATTInterface(gatt.Device): - """This class should handle GATT functionality, including: - * Connection - * Response logging - """ - - def __init__(self, mac_address, manager, args, managed=True): - super().__init__(mac_address, manager, managed) - - # Fitness Machine Service device & control point - self.ftms = None - self.ftms_control_point = None - - # bike data characteristic - self.indoor_bike_data = None - - # CLI parser arguments - self.args = args - - def set_service_or_characteristic(self, service_or_characteristic): - - # find services & characteristics of the KICKR trainer - if service_or_characteristic_found(FTMS_UUID, service_or_characteristic.uuid): - self.ftms = service_or_characteristic - elif service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, service_or_characteristic.uuid): - self.ftms_control_point = service_or_characteristic - elif service_or_characteristic_found(INDOOR_BIKE_DATA_UUID, service_or_characteristic.uuid): - self.indoor_bike_data = service_or_characteristic - self.indoor_bike_data.enable_notification() - - # ====== Log connection & characteristic update ====== - # TODO: check if those supers do anything - fairly certain they are virtual methods - - def connect_succeeded(self): - super().connect_succeeded() - logger.info("[%s] Connected" % (self.mac_address)) - - def connect_failed(self, error): - super().connect_failed(error) - logger.debug("[%s] Connection failed: %s" % (self.mac_address, str(error))) - sys.exit() - - def disconnect_succeeded(self): - super().disconnect_succeeded() - logger.info("[%s] Disconnected" % (self.mac_address)) - - def characteristic_value_updated(self, characteristic, value): - logger.debug(f"The updated value for {characteristic.uuid} is:", value) - - # ====== Control Point Response Methods ====== - - def control_point_response(self, characteristic, response_type: int, error = None): - """Handle responses from indicated control points - - virutal method to be implemented by child""" - pass - - def characteristic_write_value_succeeded(self, characteristic): - logger.debug(f"WRITE SUCCESS : {characteristic.uuid}") - self.control_point_response(characteristic,response_type=WRITE_SUCCESS) - - def characteristic_write_value_failed(self, characteristic, error): - logger.debug(f"WRITE FAIL : {characteristic.uuid} : {str(error)}") - self.control_point_response(characteristic,response_type=WRITE_FAIL,error=error) - - def characteristic_enable_notification_succeeded(self, characteristic): - logger.debug(f"NOTIFICATION ENABLED : {characteristic.uuid}") - self.control_point_response(characteristic,response_type=NOTIFICATION_SUCCESS) - - def characteristic_enable_notification_failed(self, characteristic, error): - logger.debug(f"NOTIFICATION ENABLING FAILED : {characteristic.uuid} : {str(error)}") - self.control_point_response(characteristic,response_type=NOTIFICATION_FAIL,error=error) - - # ====== Request & Resolve control point - def services_resolved(self): - super().services_resolved() - -# ======== Wahoo Controller Class ======== - -class WahooController(GATTInterface): - """This sub-class should extend the GATTInterface class to also handle: - * MQTT [or any alternative networking protocols] - * Individual Wahoo devices - * Pulling data from devices""" - - def __init__(self, mac_address, manager, args, managed=True): - super().__init__(mac_address, manager, args, managed) - - # CLI parser arguments - self.args = args - - # Wahoo devices - self.climber = Climber(self,args) - self.resistance = Resistance(self,args) - self.fan = HeadwindFan(self,args) - - self.devices = [self.climber, self.resistance, self.fan] - - # Wahoo data handler - self.wahoo_data = WahooData(self,args) - - # ===== MQTT methods ===== - - def setup_mqtt_client(self): - self.mqtt_client = MQTTClientWithSendingFTMSCommands(self.args.broker_address, self.args.username, self.args.password, self) - self.mqtt_client.setup_mqtt_client() - - def on_message(self, msg): - """Run when a subscribed MQTT topic publishes""" - for device in self.devices: - device.on_message(msg) - - def subscribe(self, topic: str, SubscribeOptions: int = 0): - """Subscribe to a MQTT topic""" - self.mqtt_client.subscribe((topic,SubscribeOptions)) - - def publish(self, topic: str, payload): - """Publish to a MQTT topic""" - self.mqtt_client.publish(topic, payload) - - def mqtt_data_report_payload(self, device_type, value): - """Create a standardised payload for MQTT publishing""" - # TODO: add more json data payload whenever needed later - return json.dumps({"value": value, "unitName": DEVICE_UNIT_NAMES[device_type], "timestamp": time.time(), "metadata": { "deviceName": platform.node() } }) - - # ===== GATT for devices ===== - - def set_service_or_characteristic(self, service_or_characteristic): - super().set_service_or_characteristic(self, service_or_characteristic) - - # TODO: add flow control by returning True if the service/characteristic was matched and then terminating the search - for device in self.devices: - device.set_service_or_characteristic(service_or_characteristic) - - def characteristic_value_updated(self, characteristic, value): - super().set_service_or_characteristic(self,characteristic,value) - - if characteristic == self.indoor_bike_data: - self.wahoo_data.process_data(value) - - def control_point_response(self, characteristic, response_type: int, error = None): - """forward responses and their types to devices""" - - # TODO: handle responses from own control point - - # forward responses - for device in self.devices: - device.control_point_response(characteristic, response_type, error) - - # ===== Resolve control point & services/characteristics - - # this is the main process that will be run all time after manager.run() is called - # FIXME: despite what is stated above - this process is not looped - it runs only once in testing. - # Maybe it reruns it until all services & characteristics have been resolved? - # TODO: Double check all the below to ensure it is working and clean it up a bit - def services_resolved(self): - super().services_resolved() - - print("[%s] Resolved services" % (self.mac_address)) - for service in self.services: - print("[%s]\tService [%s]" % (self.mac_address, service.uuid)) - self.set_service_or_characteristic(service) - - for characteristic in service.characteristics: - self.set_service_or_characteristic(characteristic) - print("[%s]\t\tCharacteristic [%s]" % (self.mac_address, characteristic.uuid)) - print("The characteristic value is: ", characteristic.read_value()) - - # TODO: check if it is necessary to filter by service - if so rewrite set_service_or_characteristic to take a service arg - """ - if self.ftms == service: - # set for FTMS control point for resistance control - self.set_service_or_characteristic(characteristic) - if self.custom_incline_service == service: - # set for custom control point for incline control - self.set_service_or_characteristic(characteristic) - """ - - # continue if FTMS service is found from the BLE device - if self.ftms and self.indoor_bike_data: - - # request control point - self.ftms_control_point.write_value(bytearray([FTMS_REQUEST_CONTROL])) - - # start looping MQTT messages - self.mqtt_client.loop_start() - - - - -# ======== Wahoo Device Classes ======== - -class WahooDevice: - """A virtual class for Wahoo devices""" - - def __init__(self, controller: WahooController, args): - - # device controller - self.controller = controller - - # CLI parser arguments - self.args = args - - # command topic - self.command_topic = None - - # report topic - self.report_topic = None - - # device control point service & characteristic - self.control_point_service = None - self.control_point = None - - # constants for threading - self._TIMEOUT = 10 - - def set_control_point(self, service_or_characteristic): - """Set UUID of passed service/characteristic if it is a required control point service/characteristic - - To be implemented by subclass""" - pass - - def on_message(self, msg): - """Receive subscribed MQTT messages - - To be implemented by subclass""" - pass - - def control_point_response(self, characteristic, response_type: int, error = None): - """Handle responses from control point. Responses should be used for flow control of threading. - - To be implemented by subclass""" - pass - -class Climber(WahooDevice): - """Handles control of the KICKR Climb""" - - def __init__(self, controller: WahooController, args): - super().__init__(self,controller,args) - - # device variable - self._incline = 0 - self._new_incline = None - - # command topic - self.command_topic = self.args.incline_command_topic - self.controller.subscribe(self.command_topic) - - # report topic - self.report_topic = self.args.incline_report_topic - - # threading - self.terminate_write = False - self.write_timeout_count = 0 - self.write_thread = None - - def set_control_point(self, service_or_characteristic): - - # find the custom KICKR climb control point service & characteristic - if service_or_characteristic_found_full_match(INCLINE_CONTROL_SERVICE_UUID, service_or_characteristic.uuid): - self.control_point_service = service_or_characteristic - elif service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, service_or_characteristic.uuid): - self.control_point = service_or_characteristic - # TODO: check whether this requires that the FTMS control point has already successfully been requested control of - self.control_point.enable_notification() - - def on_message(self, msg): - """Receive MQTT messages""" - - # check if it is the incline topic - if bool(re.search("/incline", msg.topic, re.IGNORECASE)): - - # convert, validate, and write the new value - value = str(msg.payload, 'utf-8') - if bool(re.search("[-+]?\d+$", value)): - value = float(value) - if INCLINE_MIN <= value <= INCLINE_MAX and value % 0.5 == 0: - self.incline = value - else: - logger.debug(f'INCLINE MQTT COMMAND FAIL : value must be in range 19 to -10 with 0.5 resolution : {value}') - else: - logger.debug(f'INCLINE MQTT COMMAND FAIL : non-numeric value sent : {value}') - - def report(self): - """Report a successful incline write""" - payload = self.controller.mqtt_data_report_payload('incline',self._incline) - self.controller.publish(self.report_topic,payload) - - def control_point_response(self, characteristic, response_type: int, error = None): - """Handle responses from the control point""" - - # if the response is not from the relevant control point then return - if characteristic.uuid != self.control_point.uuid: return - - # on successful write terminate the thread and update the internal incline value - if response_type == WRITE_SUCCESS: - # TODO: LOCK this property at start of method - self.terminate_write = True - self._incline = self._new_incline - self.report() - logger.debug(f'INCLINE WRITE SUCCESS: {self._incline}') - - # on failed write try writing again until timeout - elif response_type == WRITE_FAIL: - self.write_timeout_count += 1 - self.write_thread.start() - logger.debug(f'INCLINE WRITE FAILED: {self._new_incline}') - - # TODO: Add check that we are notifying the correct characteristic - handling error responses - # on successful enabling of notification on control point log - elif response_type == NOTIFICATION_SUCCESS: - logger.debug('INCLINE NOTIFICATION SUCCESS') - - # on fail to enable notification on control point try again - elif response_type == NOTIFICATION_FAIL: - logger.debug('INCLINE NOTIFICATION FAILED') - self.control_point.enable_notification() - - @property - def incline(self,val): - """Try to write a new incline value until timeout, success or new value to be written""" - - # define the new value internally - self._new_incline = val - - # terminate any current threads - # TODO: LOCK this property at start of method - self.terminate_write = True - self.write_thread.join() - - # setup & start new thread - self.terminate_write = False - self.write_timeout_count = 0 - self.write_thread = threading.Thread(name='write_new_incline',target=self.write_new_incline,args=(self._new_incline)) - self.write_thread.start() - - def write_new_incline(self): - """Attempt to write the new incline value until successful or forced to terminate""" - - # write the new value until termination or timeout - if not (self.terminate_write and self.write_timeout_count >= self._TIMEOUT): - self.control_point.write_value(bytearray([INCLINE_CONTROL_OP_CODE] + convert_incline_to_op_value(self._new_incline))) - - -class Resistance(WahooDevice): - """Handles control of the Resistance aspect of the KICKR Smart Trainer""" - - def __init__(self, controller: WahooController, args): - super().__init__(self,controller,args) - - # device variable - self._resistance = 0 - - # command topic - self.command_topic = self.args.resistance_command_topic - self.controller.subscribe(self.command_topic) - - # report topic - self.report_topic = self.args.resistance_report_topic - - def set_control_point(self, service_or_characteristic): - - # setup aliases for the FTMS control point service & characteristic - self.control_point_service = self.controller.ftms - self.control_point = self.controller.ftms_control_point - - def on_message(self, msg): - """Receive MQTT messages""" - pass - - # ========================================= - - # TODO: add response reactions to Resistance class - """def characteristic_write_value_succeeded(self, characteristic): - - # set new resistance or inclination and notify to MQTT if the async write value action is succeeded - if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): - if self.new_resistance is not None: - self.set_new_resistance()""" - - # ========================================== - -class HeadwindFan(WahooDevice): - """Handles control of the KICKR Headwind Smart Bluetooth Fan""" - - def __init__(self, controller: WahooController, args): - super().__init__(self,controller,args) - - # device variable - self._fan_power = 0 - - def on_message(self, msg): - """Receive MQTT messages""" - pass - -""" -Class to handle data from Wahoo devices. All data is streamed through the KICKR smart trainer so best handled by a single class. -All code copied over from old code so it could use a clean up. -A lot of the processed data is not relevant - most are for Wahoo devices we do not have - but someone put the effort into developing -those bits so might as well keep it in case we use it in the future. -I think things could be cleaned up a lot on the pull_value method but need better undertsanding of handling bit data. -""" - -class WahooData: - - def __init__(self, controller: WahooController, args): - - # device controller - self.controller = controller - - # CLI parser arguments - self.args = args - - # track if bike is new data - self.idle = False - - # data flags - """Many of these are for Wahoo devices that we are not using/do not have""" - self.flag_instantaneous_speed = None - self.flag_average_speed = None - self.flag_instantaneous_cadence = None - self.flag_average_cadence = None - self.flag_total_distance = None - self.flag_resistance_level = None - self.flag_instantaneous_power = None - self.flag_average_power = None - self.flag_expended_energy = None - self.flag_heart_rate = None - self.flag_metabolic_equivalent = None - self.flag_elapsed_time = None - self.flag_remaining_time = None - - # data values - self.instantaneous_speed = None - self.average_speed = None - self.instantaneous_cadence = None - self.average_cadence = None - self.total_distance = None - self.resistance_level = None - self.instantaneous_power = None - self.average_power = None - self.expended_energy_total = None - self.expended_energy_per_hour = None - self.expended_energy_per_minute = None - self.heart_rate = None - self.metabolic_equivalent = None - self.elapsed_time = None - self.remaining_time = None - - def process_data(self, value): - self.reported_data(value) - self.pull_value(value) - self.publish_data() - - def reported_data(self, value): - """Check the received bit data for which data was reported""" - self.flag_instantaneous_speed = not((value[0] & 1) >> 0) - self.flag_average_speed = (value[0] & 2) >> 1 - self.flag_instantaneous_cadence = (value[0] & 4) >> 2 - self.flag_average_cadence = (value[0] & 8) >> 3 - self.flag_total_distance = (value[0] & 16) >> 4 - self.flag_resistance_level = (value[0] & 32) >> 5 - self.flag_instantaneous_power = (value[0] & 64) >> 6 - self.flag_average_power = (value[0] & 128) >> 7 - self.flag_expended_energy = (value[1] & 1) >> 0 - self.flag_heart_rate = (value[1] & 2) >> 1 - self.flag_metabolic_equivalent = (value[1] & 4) >> 2 - self.flag_elapsed_time = (value[1] & 8) >> 3 - self.flag_remaining_time = (value[1] & 16) >> 4 - - def pull_value(self, value): - """Get the reported data from the bit data""" - offset = 2 - - if self.flag_instantaneous_speed: - self.instantaneous_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 - offset += 2 - logger.info(f"Instantaneous Speed: {self.instantaneous_speed} m/s") - - if self.flag_average_speed: - self.average_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 - offset += 2 - logger.info(f"Average Speed: {self.average_speed} m/s") - - if self.flag_instantaneous_cadence: - self.instantaneous_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 - offset += 2 - logger.info(f"Instantaneous Cadence: {self.instantaneous_cadence} rpm") - - if self.flag_average_cadence: - self.average_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 - offset += 2 - logger.info(f"Average Cadence: {self.average_cadence} rpm") - - if self.flag_total_distance: - self.total_distance = int((value[offset+2] << 16) + (value[offset+1] << 8) + value[offset]) - offset += 3 - logger.info(f"Total Distance: {self.total_distance} m") - - if self.flag_resistance_level: - self.resistance_level = int((value[offset+1] << 8) + value[offset]) - offset += 2 - logger.info(f"Resistance Level: {self.resistance_level}") - - if self.flag_instantaneous_power: - self.instantaneous_power = int((value[offset+1] << 8) + value[offset]) - offset += 2 - logger.info(f"Instantaneous Power: {self.instantaneous_power} W") - - if self.flag_average_power: - self.average_power = int((value[offset+1] << 8) + value[offset]) - offset += 2 - logger.info(f"Average Power: {self.average_power} W") - - if self.flag_expended_energy: - expended_energy_total = int((value[offset+1] << 8) + value[offset]) - offset += 2 - if expended_energy_total != 0xFFFF: - self.expended_energy_total = expended_energy_total - logger.info(f"Expended Energy: {self.expended_energy_total} kCal total") - - expended_energy_per_hour = int((value[offset+1] << 8) + value[offset]) - offset += 2 - if expended_energy_per_hour != 0xFFFF: - self.expended_energy_per_hour = expended_energy_per_hour - logger.info(f"Expended Energy: {self.expended_energy_per_hour} kCal/hour") - - expended_energy_per_minute = int(value[offset]) - offset += 1 - if expended_energy_per_minute != 0xFF: - self.expended_energy_per_minute = expended_energy_per_minute - logger.info(f"Expended Energy: {self.expended_energy_per_minute} kCal/min") - - if self.flag_heart_rate: - self.heart_rate = int(value[offset]) - offset += 1 - logger.info(f"Heart Rate: {self.heart_rate} bpm") - - if self.flag_metabolic_equivalent: - self.metabolic_equivalent = float(value[offset]) / 10.0 - offset += 1 - logger.info(f"Metabolic Equivalent: {self.metabolic_equivalent} METS") - - if self.flag_elapsed_time: - self.elapsed_time = int((value[offset+1] << 8) + value[offset]) - offset += 2 - logger.info(f"Elapsed Time: {self.elapsed_time} seconds") - - if self.flag_remaining_time: - self.remaining_time = int((value[offset+1] << 8) + value[offset]) - offset += 2 - logger.info(f"Remaining Time: {self.remaining_time} seconds") - - if offset != len(value): - logger.error("ERROR: Payload was not parsed correctly") - return - - def publish_data(self): - """Publish if data or log that there was no relevant data""" - - if self.instantaneous_speed > 0: - self.idle = False - self.publish() - else: - if self.idle: - logger.info('Bike currently idle, no data published') - else: - self.publish() - self.idle = True - - def publish(self): - """Publish data""" - - if self.flag_instantaneous_speed: - self.controller.publish(self.args.speed_report_topic, self.controller.mqtt_data_report_payload('speed', self.instantaneous_speed)) - if self.flag_instantaneous_cadence: - self.controller.mqtt_client.publish(self.args.cadence_report_topic, self.controller.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) - if self.flag_instantaneous_power: - self.controller.mqtt_client.publish(self.args.power_report_topic, self.controller.mqtt_data_report_payload('power', self.instantaneous_power)) - - - - diff --git a/Drivers/wahoo_device_controller/wahoo_controller_starter.py b/Drivers/wahoo_device_controller/wahoo_controller_starter.py deleted file mode 100644 index 6ce17965..00000000 --- a/Drivers/wahoo_device_controller/wahoo_controller_starter.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys -import gatt -from argparse import ArgumentParser -from wahoo_controller import WahooController - -root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.append(root_folder) - -from lib.constants import BIKE_01_INCLINE_COMMAND, BIKE_01_RESISTANCE_COMMAND, BIKE_01_INCLINE_REPORT, BIKE_01_RESISTANCE_REPORT, BIKE_01_SPEED_REPORT, BIKE_01_CADENCE_REPORT, BIKE_01_POWER_REPORT - -# define CLI parse arguments -parser = ArgumentParser(description="Wahoo Kickr Incline and Resistance Control") - -# BLE connection params -parser.add_argument('--mac_address', dest='mac_address', type=str, help="The Wahoo Kickr BLE Device's unique mac address") - -# HiveMQ connection params -parser.add_argument('--broker_address', dest='broker_address', type=str, help='The MQTT broker address getting from HiveMQ Cloud') -parser.add_argument('--username', dest='username', type=str, help='HiveMQ Cloud username') -parser.add_argument('--password', dest='password', type=str, help='HiveMQ Cloud password') - -parser.add_argument('--incline_command_topic', dest='incline_command_topic', type=str, help='a MQTT topic that will send incline or resistance control commands to this driver', default=BIKE_01_INCLINE_COMMAND) -parser.add_argument('--incline_report_topic', dest='incline_report_topic', type=str, help='a MQTT topic that will receieve the current incline or resistance levels data from this driver', default=BIKE_01_INCLINE_REPORT) -parser.add_argument('--resistance_command_topic', dest='resistance_command_topic', type=str, help='a MQTT topic that will send incline or resistance control commands to this driver', default=BIKE_01_RESISTANCE_COMMAND) -parser.add_argument('--resistance_report_topic', dest='resistance_report_topic', type=str, help='a MQTT topic that will receieve the current incline or resistance levels data from this driver', default=BIKE_01_RESISTANCE_REPORT) -parser.add_argument('--speed_report_topic', dest='speed_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous speed data in m/s from this driver', default=BIKE_01_SPEED_REPORT) -parser.add_argument('--cadence_report_topic', dest='cadence_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous cadence data in rpm from this driver', default=BIKE_01_CADENCE_REPORT) -parser.add_argument('--power_report_topic', dest='power_report_topic', type=str, help='a MQTT topic that will receive the current instantaneous power data in W from this driver', default=BIKE_01_POWER_REPORT) - -args = parser.parse_args() - -print("Connecting to the BLE device...") -manager = gatt.DeviceManager(adapter_name='hci0') - -# initiate and connect a WahooDevice with a given BLE mac_address -device = WahooController(manager=manager, mac_address=args.mac_address, args=args) -device.connect() - -try: - print("Running the device manager now...") - # run the device manager in the main thread forever - manager.run() -except KeyboardInterrupt: - print ('Exit the program.') - manager.stop() - sys.exit(0) diff --git a/Drivers/wahoo_device_controller/wahoo_device.py b/Drivers/wahoo_device_controller/wahoo_device.py deleted file mode 100644 index 76dad0dd..00000000 --- a/Drivers/wahoo_device_controller/wahoo_device.py +++ /dev/null @@ -1,428 +0,0 @@ -from pickle import TRUE -import re -import os -from sqlite3 import Timestamp -import sys -import gatt -import platform -import json -import time -from time import sleep -from mqtt_custom_client import MQTTClientWithSendingFTMSCommands -import threading -import logging - -root_folder = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.append(root_folder) - -from lib.ble_helper import convert_incline_to_op_value, service_or_characteristic_found, service_or_characteristic_found_full_match, decode_int_bytes, covert_negative_value_to_valid_bytes -from lib.constants import RESISTANCE_MIN, RESISTANCE_MAX, INCLINE_MIN, INCLINE_MAX, FTMS_UUID, RESISTANCE_LEVEL_RANGE_UUID, INCLINATION_RANGE_UUID, FTMS_CONTROL_POINT_UUID, FTMS_REQUEST_CONTROL, FTMS_RESET, FTMS_SET_TARGET_RESISTANCE_LEVEL, INCLINE_REQUEST_CONTROL, INCLINE_CONTROL_OP_CODE, INCLINE_CONTROL_SERVICE_UUID, INCLINE_CONTROL_CHARACTERISTIC_UUID, INDOOR_BIKE_DATA_UUID, DEVICE_UNIT_NAMES - -# a sleep time to wait for a characteristic.writevalue() action to be completed -WRITEVALUE_WAIT_TIME = 0.1 # TODO: If this doesn't work well, it needs to change this short sleep mechainism to a async process mechainism for sending consequetive BLE commands (eg., threading control) - -# Wahoo Device Interface -""" -WARNING: This code was directly copied from `kickr_climb_and_smart_trainer/wahoo_device.py` and then modified - so some unexpected artifacts may be present. - -This class should act as an interface between the game communication and the individual Wahoo devices. -It should: -* Establish communication with the bike. -* Initialise the device controllers. -* Establish communication with the game. -* Instigate reading device data. -* Instigate writing device commands. -* Listen for game publications. -* Publish device data. -* Modularly structured: - 0 The communication methods should be segregated from their applications. - 0 Reason: Basically we want to be able to switch from MQTT easily - so if a controller returns data from the bike to the interface, then the interface will call a method to push that data to the game or mobile app. - Code using MQTT, CoAP or any other communication method we select to use should only appear in this final method during this process or similiar processes. -""" - -class WahooDevice(gatt.Device): - def __init__(self, mac_address, manager, args, managed=True): - super().__init__(mac_address, manager, managed) - - # define the initial FTMS Service and the corresponding Characteristics - self.ftms = None - self.inclination_range = None - self.resistance_level_range = None - self.ftms_control_point = None - - # define the initial Incline Service and the corresponding Characteristics - self.custom_incline_service = None - self.custom_incline_characteristic = None - - # define the initial resistance and inclination values - self.resistance = 0 - self.inclination = 0 - self.new_resistance = None - self.new_inclination = None - - # define the Characteristics for Indoor Bike Data (reporting speed, cadence and power) - self.indoor_bike_data = None - - # CLI parser arguments - self.args = args - - # Zero count - self.zero_count = 0 - - # setup MQTT connection - self.setup_mqtt_connection() - - # ===== Controller Specific Methods ===== - - # ===== MQTT methods ===== - - def setup_mqtt_connection(self): - self.mqtt_client = MQTTClientWithSendingFTMSCommands(self.args.broker_address, self.args.username, self.args.password, self) - self.mqtt_client.setup_mqtt_client() - - # subscribe to both resistance and incline command topics - self.mqtt_client.subscribe([(self.args.resistance_command_topic, 0), (self.args.incline_command_topic, 0)]) - - # ===== GATT methods ===== - - def set_service_or_characteristic(self, service_or_characteristic): - if service_or_characteristic_found(FTMS_UUID, service_or_characteristic.uuid): - self.ftms = service_or_characteristic - elif service_or_characteristic_found_full_match(INCLINE_CONTROL_SERVICE_UUID, service_or_characteristic.uuid): - self.custom_incline_service = service_or_characteristic - elif service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, service_or_characteristic.uuid): - self.custom_incline_characteristic = service_or_characteristic - elif service_or_characteristic_found(INCLINATION_RANGE_UUID, service_or_characteristic.uuid): - self.inclination_range = service_or_characteristic - elif service_or_characteristic_found(RESISTANCE_LEVEL_RANGE_UUID, service_or_characteristic.uuid): - self.resistance_level_range = service_or_characteristic - elif service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, service_or_characteristic.uuid): - self.ftms_control_point = service_or_characteristic - elif service_or_characteristic_found(INDOOR_BIKE_DATA_UUID, service_or_characteristic.uuid): - self.indoor_bike_data = service_or_characteristic - - def connect_succeeded(self): - super().connect_succeeded() - print("[%s] Connected" % (self.mac_address)) - - def connect_failed(self, error): - super().connect_failed(error) - print("[%s] Connection failed: %s" % (self.mac_address, str(error))) - sys.exit() - - def disconnect_succeeded(self): - super().disconnect_succeeded() - print("[%s] Disconnected" % (self.mac_address)) - - def ftms_request_control(self): - if self.ftms_control_point: - # request FTMS control - print("Requesting FTMS control...") - self.ftms_control_point.write_value(bytearray([FTMS_REQUEST_CONTROL])) - sleep(WRITEVALUE_WAIT_TIME) - - def ftms_reset_settings(self): - print("Initiating to reset control settings...") - if self.ftms_control_point: - # reset FTMS control settings - print("Resetting FTMS control settings...") - self.ftms_control_point.write_value(bytearray([FTMS_RESET])) - sleep(WRITEVALUE_WAIT_TIME) - - self.resistance = 0 - self.inclination = 0 - - # request resistance control - self.ftms_request_control() - # reset resistance down to 0% - self.ftms_set_target_resistance_level(self.resistance) - - # request incline control - self.custom_control_point_enable_notifications() - # reset incline to the flat level: 0% - self.custom_control_point_set_target_inclination(self.inclination) - - self.mqtt_client.publish(self.args.incline_report_topic, self.mqtt_data_report_payload('incline', self.inclination)) - self.mqtt_client.publish(self.args.resistance_report_topic, self.mqtt_data_report_payload('resistance', self.resistance)) - - # the resistance value is UINT8 type and unitless with a resolution of 0.1 - def ftms_set_target_resistance_level(self, new_resistance): - print(f"Trying to set a new resistance value: {new_resistance}") - - if self.ftms_control_point: - # initiate the action - self.ftms_control_point.write_value(bytearray([FTMS_SET_TARGET_RESISTANCE_LEVEL, new_resistance])) - self.new_resistance = new_resistance - sleep(WRITEVALUE_WAIT_TIME) - - def custom_control_point_enable_notifications(self): - if self.custom_incline_characteristic: - # has to do this step to be able to send incline value successfully - print("Enabling notifications for custom incline endpoint...") - self.custom_incline_characteristic.enable_notifications() - sleep(WRITEVALUE_WAIT_TIME) - - # the inclination value range is -10 to 19, in Percent with a resolution of 0.5% - def custom_control_point_set_target_inclination(self, new_inclination): - print(f"Trying to set a new inclination value: {new_inclination}") - - if self.custom_incline_characteristic: - # send the new inclination value to the custom characteristic - print("values are: ", convert_incline_to_op_value(new_inclination)) - self.custom_incline_characteristic.write_value(bytearray([INCLINE_CONTROL_OP_CODE] + convert_incline_to_op_value(new_inclination))) - #self.custom_incline_characteristic.write_value(bytearray([0x66, 0x6c, 0x07])) - self.new_inclination = new_inclination - sleep(WRITEVALUE_WAIT_TIME) - - def set_new_inclination(self): - self.inclination = self.new_inclination - self.new_inclination = None - print(f"A new inclination has been set successfully: {self.inclination}") - self.mqtt_client.publish(self.args.incline_report_topic, self.mqtt_data_report_payload('incline', self.inclination)) - - def set_new_resistance(self): - self.resistance = self.new_resistance - self.new_resistance = None - print(f"A new resistance has been set successfully: {self.resistance}") - self.mqtt_client.publish(self.args.resistance_report_topic, self.mqtt_data_report_payload('resistance', self.resistance)) - - def set_new_inclination_failed(self): - print(f"The new inclination has not been set successfully: {self.new_inclination}") - self.new_inclination = None - - def set_new_resistance_failed(self): - print(f"The new resistance has not been set successfully: {self.new_resistance}") - self.new_resistance = None - - def descriptor_read_value_failed(self, descriptor, error): - print('descriptor value read failed:', str(error)) - - def characteristic_value_updated(self, characteristic, value): - print(f"The updated value for {characteristic.uuid} is:", value) - - def characteristic_write_value_succeeded(self, characteristic): - print(f"A new value has been written to {characteristic.uuid}") - - # set new resistance or inclination and notify to MQTT if the async write value action is succeeded - if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): - if self.new_resistance is not None: - self.set_new_resistance() - if service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, characteristic.uuid): - if self.new_inclination is not None: - self.set_new_inclination() - - def characteristic_write_value_failed(self, characteristic, error): - print(f"A new value has not been written to {characteristic.uuid} successfully: {str(error)}") - - # clear the new resistance or inclination and notify to MQTT if the async write value action is failed - if service_or_characteristic_found(FTMS_CONTROL_POINT_UUID, characteristic.uuid): - if self.new_resistance is not None: - self.set_new_resistance_failed() - if service_or_characteristic_found_full_match(INCLINE_CONTROL_CHARACTERISTIC_UUID, characteristic.uuid): - if self.new_inclination is not None: - self.set_new_inclination_failed() - - def characteristic_enable_notification_succeeded(self, characteristic): - print(f"The {characteristic.uuid} has been enabled with notification!") - - def characteristic_enable_notification_failed(self, characteristic, error): - print(f"Cannot enable notification for {characteristic.uuid}: {str(error)}") - - # process the Indoor Bike Data - def process_indoor_bike_data(self, value): - flag_instantaneous_speed = not((value[0] & 1) >> 0) - flag_average_speed = (value[0] & 2) >> 1 - flag_instantaneous_cadence = (value[0] & 4) >> 2 - flag_average_cadence = (value[0] & 8) >> 3 - flag_total_distance = (value[0] & 16) >> 4 - flag_resistance_level = (value[0] & 32) >> 5 - flag_instantaneous_power = (value[0] & 64) >> 6 - flag_average_power = (value[0] & 128) >> 7 - flag_expended_energy = (value[1] & 1) >> 0 - flag_heart_rate = (value[1] & 2) >> 1 - flag_metabolic_equivalent = (value[1] & 4) >> 2 - flag_elapsed_time = (value[1] & 8) >> 3 - flag_remaining_time = (value[1] & 16) >> 4 - offset = 2 - - if flag_instantaneous_speed: - self.instantaneous_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 - offset += 2 - print(f"Instantaneous Speed: {self.instantaneous_speed} m/s") - - if flag_average_speed: - self.average_speed = float((value[offset+1] << 8) + value[offset]) / 100.0 * 5.0 / 18.0 - offset += 2 - print(f"Average Speed: {self.average_speed} m/s") - - if flag_instantaneous_cadence: - self.instantaneous_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 - offset += 2 - print(f"Instantaneous Cadence: {self.instantaneous_cadence} rpm") - - if flag_average_cadence: - self.average_cadence = float((value[offset+1] << 8) + value[offset]) / 10.0 - offset += 2 - print(f"Average Cadence: {self.average_cadence} rpm") - - if flag_total_distance: - self.total_distance = int((value[offset+2] << 16) + (value[offset+1] << 8) + value[offset]) - offset += 3 - print(f"Total Distance: {self.total_distance} m") - - if flag_resistance_level: - self.resistance_level = int((value[offset+1] << 8) + value[offset]) - offset += 2 - print(f"Resistance Level: {self.resistance_level}") - - if flag_instantaneous_power: - self.instantaneous_power = int((value[offset+1] << 8) + value[offset]) - offset += 2 - print(f"Instantaneous Power: {self.instantaneous_power} W") - - if flag_average_power: - self.average_power = int((value[offset+1] << 8) + value[offset]) - offset += 2 - print(f"Average Power: {self.average_power} W") - - if flag_expended_energy: - expended_energy_total = int((value[offset+1] << 8) + value[offset]) - offset += 2 - if expended_energy_total != 0xFFFF: - self.expended_energy_total = expended_energy_total - print(f"Expended Energy: {self.expended_energy_total} kCal total") - - expended_energy_per_hour = int((value[offset+1] << 8) + value[offset]) - offset += 2 - if expended_energy_per_hour != 0xFFFF: - self.expended_energy_per_hour = expended_energy_per_hour - print(f"Expended Energy: {self.expended_energy_per_hour} kCal/hour") - - expended_energy_per_minute = int(value[offset]) - offset += 1 - if expended_energy_per_minute != 0xFF: - self.expended_energy_per_minute = expended_energy_per_minute - print(f"Expended Energy: {self.expended_energy_per_minute} kCal/min") - - if flag_heart_rate: - self.heart_rate = int(value[offset]) - offset += 1 - print(f"Heart Rate: {self.heart_rate} bpm") - - if flag_metabolic_equivalent: - self.metabolic_equivalent = float(value[offset]) / 10.0 - offset += 1 - print(f"Metabolic Equivalent: {self.metabolic_equivalent} METS") - - if flag_elapsed_time: - self.elapsed_time = int((value[offset+1] << 8) + value[offset]) - offset += 2 - print(f"Elapsed Time: {self.elapsed_time} seconds") - - if flag_remaining_time: - self.remaining_time = int((value[offset+1] << 8) + value[offset]) - offset += 2 - print(f"Remaining Time: {self.remaining_time} seconds") - - if offset != len(value): - print("ERROR: Payload was not parsed correctly") - return - - # The KICKR Trainer only reports instantaneous speed, cadence and power - # Publish them to MQTT topics if they were provided - if self.zero_count < 10: - if flag_instantaneous_speed: - self.mqtt_client.publish(self.args.speed_report_topic, self.mqtt_data_report_payload('speed', self.instantaneous_speed)) - if flag_instantaneous_cadence: - self.mqtt_client.publish(self.args.cadence_report_topic, self.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) - if flag_instantaneous_power: - self.mqtt_client.publish(self.args.power_report_topic, self.mqtt_data_report_payload('power', self.instantaneous_power)) - - if self.instantaneous_speed == 0: - self.zero_count += 1 - - print('Zero Count:', self.zero_count) - - elif self.zero_count >= 10 and self.instantaneous_speed > 0: - self.zero_count = 0 - if flag_instantaneous_speed: - self.mqtt_client.publish(self.args.speed_report_topic, self.mqtt_data_report_payload('speed', self.instantaneous_speed)) - if flag_instantaneous_cadence: - self.mqtt_client.publish(self.args.cadence_report_topic, self.mqtt_data_report_payload('cadence', self.instantaneous_cadence)) - if flag_instantaneous_power: - self.mqtt_client.publish(self.args.power_report_topic, self.mqtt_data_report_payload('power', self.instantaneous_power)) - else: - print('Bike currently idle, no data publish to MQTT') - - def mqtt_data_report_payload(self, device_type, value): - # TODO: add more json data payload whenever needed later - return json.dumps({"value": value, "unitName": DEVICE_UNIT_NAMES[device_type], "timestamp": time.time(), "metadata": { "deviceName": platform.node() } }) - - # this will be called with updates from any characteristics which provide notifications (eg. Indoor Bike Data) - def characteristic_value_updated(self, characteristic, value): - if characteristic == self.indoor_bike_data: - self.process_indoor_bike_data(value) - - def on_message(self, msg): - # positve number for resistance value; positve/negative number for inclination value - value = str(msg.payload, 'utf-8') - print(f"[MQTT message received for Topic: '{msg.topic}', QOS: {str(msg.qos)}] ", str(value)) - - if bool(re.search("[-+]?\d+$", value)): - int_value = int(value) - if bool(re.search("/incline", msg.topic, re.IGNORECASE)): - if int_value > INCLINE_MAX or int_value < INCLINE_MIN: - message = f"Skip invalid incline value: {int_value} (the range has to be: {INCLINE_MIN}% - {INCLINE_MAX}%)" - print(message) - - self.publish(self.device.args.incline_report_topic, message) - else: - self.device.custom_control_point_set_target_inclination(int_value) - elif bool(re.search("/resistance", msg.topic, re.IGNORECASE)): - if int_value > RESISTANCE_MAX or int_value < RESISTANCE_MIN: - message = f"Skip invalid resistance value: {int_value}" - print(message) - - self.publish(self.device.args.resistance_report_topic, message) - else: - self.device.ftms_set_target_resistance_level(int_value) - else: - print("The command topic is not idetified.") - else: - print("Skip the invalid command payload.") - - # this is the main process that will be run all time after manager.run() is called - # FIXME: despite what is stated above - this process is not looped - it runs only once in testing. - def services_resolved(self): - super().services_resolved() - - print("[%s] Resolved services" % (self.mac_address)) - for service in self.services: - print("[%s]\tService [%s]" % (self.mac_address, service.uuid)) - self.set_service_or_characteristic(service) - - for characteristic in service.characteristics: - print("[%s]\t\tCharacteristic [%s]" % (self.mac_address, characteristic.uuid)) - print("The characteristic value is: ", characteristic.read_value()) - - if self.ftms == service: - # set for FTMS control point for resistance control - self.set_service_or_characteristic(characteristic) - if self.custom_incline_service == service: - # set for custom control point for incline control - self.set_service_or_characteristic(characteristic) - - # continue if FTMS service is found from the BLE device - if self.ftms and self.indoor_bike_data: - # read the supported resistance and inclination ranges here to set correct command values later - self.read_resistance_level_range() - self.read_inclination_range() - - # enable notifications for Indoor Bike Data - self.indoor_bike_data.enable_notifications() - - # reset control settings while initiating the BLE connection - self.ftms_reset_settings() - - # start looping MQTT messages - self.mqtt_client.loop_start() \ No newline at end of file