From 9b45309f2bdf538fdbb1013fc74e198d26717524 Mon Sep 17 00:00:00 2001 From: ghotighola-collab Date: Mon, 20 Oct 2025 20:50:41 +0100 Subject: [PATCH 1/6] Fix indent in smartrow and add support for BLE FTMS rower --- install.sh | 45 +++-- src/adapters/ftmsrower/ftmsrowerreader.py | 144 ++++++++++++++ src/adapters/ftmsrower/ftmsrowtobleant.py | 184 +++++++++++++++++ src/adapters/smartrow/smartrowtobleant.py | 232 +++++++++++----------- src/waterrowerthreads.py | 17 +- 5 files changed, 483 insertions(+), 139 deletions(-) create mode 100755 src/adapters/ftmsrower/ftmsrowerreader.py create mode 100755 src/adapters/ftmsrower/ftmsrowtobleant.py diff --git a/install.sh b/install.sh index 126b198..41776ec 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,7 @@ echo "-------------------------------------------------------------" echo "updates the list of latest updates available for the packages" echo "-------------------------------------------------------------" echo " " -sudo apt-get update +#sudo apt-get update echo " " echo "----------------------------------------------" @@ -34,22 +34,22 @@ echo "install needed packages for python " echo "----------------------------------------------" echo " " -sudo apt-get install -y \ - python3 \ - python3-gi \ - python3-gi-cairo \ - gir1.2-gtk-3.0 \ - python3-pip \ - libatlas-base-dev \ - libdbus-1-dev \ - libglib2.0-dev \ - libgirepository1.0-dev \ - libcairo2-dev \ - zlib1g-dev \ - libfreetype6-dev \ - liblcms2-dev \ - libopenjp2-7 \ - libtiff6 +#sudo apt-get install -y \ +# python3 \ +# python3-gi \ +# python3-gi-cairo \ +# gir1.2-gtk-3.0 \ +# python3-pip \ +# libatlas-base-dev \ +# libdbus-1-dev \ +# libglib2.0-dev \ +# libgirepository1.0-dev \ +# libcairo2-dev \ +# zlib1g-dev \ +# libfreetype6-dev \ +# liblcms2-dev \ +# libopenjp2-7 \ +# libtiff5 echo " " echo "----------------------------------------------" @@ -57,8 +57,9 @@ echo "set up virtual environment " echo "----------------------------------------------" echo " " -python3 -m venv venv -source venv/bin/activate +#python3 -m venv venv +#sleep 10 +#source venv/bin/activate echo " " echo "----------------------------------------------" @@ -66,8 +67,8 @@ echo "install needed python3 modules for the project " echo "----------------------------------------------" echo " " -pip install --upgrade pip -pip install -r requirements.txt +#pip install --upgrade pip +#pip install -r requirements.txt echo " " echo "-------------------------------------------------------" @@ -170,7 +171,7 @@ echo " setup screen setting to start up at boot " echo "------------------------------------------------------------" echo " " -sudo sed -i 's/#dtparam=spi=on/dtparam=spi=on/g' /boot/firmware/config.txt +sudo sed -i 's/#dtparam=spi=on/dtparam=spi=on/g' /boot/config.txt cp src/adapters/screen/settings.ini.orig src/adapters/screen/settings.ini sudo sed -i 's@#REPO_DIR#@'"$repo_dir"'@g' src/adapters/screen/settings.ini diff --git a/src/adapters/ftmsrower/ftmsrowerreader.py b/src/adapters/ftmsrower/ftmsrowerreader.py new file mode 100755 index 0000000..bab8224 --- /dev/null +++ b/src/adapters/ftmsrower/ftmsrowerreader.py @@ -0,0 +1,144 @@ +import gatt +import logging +import threading +from time import time + +logger = logging.getLogger(__name__) + +#This SDK requires you to create subclasses of gatt.DeviceManager and gatt.Device. The other two classes gatt.Service and gatt.Characteristic are not supposed to be subclassed. + +#The SDK entry point is the DeviceManager class. Check the following example to dicover any Bluetooth Low Energy device nearby. + + +class FTMS_Rower(gatt.Device): + + SERVICE_UUID_FTMSROWER = "00001826-0000-1000-8000-00805f9b34fb" + CHARACTERISTIC_UUID_ROWWRITE = "00002ad9-0000-1000-8000-00805f9b34fb" + CHARACTERISTIC_UUID_ROWDATA = "00002ad1-0000-1000-8000-00805f9b34fb" + + def __init__(self, mac_address, manager): + super().__init__(mac_address=mac_address, manager=manager) + self._callbacks = set() + self.lock = threading.Lock() + self.is_connected = False + + def ready(self): + with self.lock: #"Lock Acquired" + return self.is_connected + + def connect_succeeded(self): + super().connect_succeeded() + logger.info("Connected to [{}]".format(self.mac_address)) + + + def connect_failed(self, error): + super().connect_failed(error) + logger.info("Connection failed [{}]: {}".format(self.mac_address, error)) + + def disconnect_succeeded(self): + super().disconnect_succeeded() + logger.info("Disconnected [{}]".format(self.mac_address)) + #self.connect() + + def find_service(self, uuid): + for service in self.services: + if service.uuid == uuid: + return service + + return None + + def find_characteristic(self, service, uuid): + for chrstc in service.characteristics: + if chrstc.uuid == uuid: + return chrstc + + return None + + def services_resolved(self): + super().services_resolved() + + logger.info("Resolved services [{}]".format(self.mac_address)) + for service in self.services: + logger.info("\t[{}] Service [{}]".format(self.mac_address, service.uuid)) + for characteristic in service.characteristics: + logger.info("\t\tCharacteristic [{}]".format(characteristic.uuid)) + + self.serviceFTMS_Rower = self.find_service(self.SERVICE_UUID_FTMSROWER) + self.chrstcRowData = self.find_characteristic(self.serviceFTMS_Rower, self.CHARACTERISTIC_UUID_ROWDATA) + self.chrstcRowData.enable_notifications() + + self.chrstcRowWrite = self.find_characteristic(self.serviceFTMS_Rower, self.CHARACTERISTIC_UUID_ROWWRITE) + with self.lock: #"Lock Acquired" + self.is_connected = True + + def characteristic_value_updated(self, characteristic, value): + super().characteristic_value_updated(characteristic, value) + #self.buffer = value.decode() + self.notify_callbacks(value) + + + def characteristic_write_value(self, value): + self.writing = value + #print(value) + self.chrstcRowWrite.write_value(value) + + def register_callback(self, cb): + self._callbacks.add(cb) + + def remove_callback(self, cb): + self._callbacks.remove(cb) + + def notify_callbacks(self, event): + for cb in self._callbacks: + cb(event) + +class FTMS_Row_Manager(gatt.DeviceManager): + def __init__(self,*args,**kwargs): + gatt.DeviceManager.__init__(self, *args, **kwargs) + self.lock = threading.Lock() + self.discovered=False + + def ready(self): + with self.lock: + return self.discovered + + def device_discovered(self, device): + # Better to allow an argument for the FTMS device alias? + if device.alias() == "CR 71": + logging.info("found FTMS Rower") + logging.info(device.mac_address) + self.ftmsrowmac = device.mac_address + self.stop() + with self.lock: #"Lock Acquired" + self.discovered=True + + +def connecttoftmsrow(): + manager = FTMS_Row_Manager(adapter_name='hci0') + logger.info("starting discovery") + manager.start_discovery() # from the DeviceManager class call the methode start_discorvery + manager.run() + while not manager.ready(): # hold the thread locked a checks if FTMS Rower has been found. Then gives other process 0.2 sec time to work + time.sleep(0.2) + logger.info("found FTMS Row macaddress") + macaddressftmsrower = manager.ftmsrowmac + return macaddressftmsrower + + +if __name__ == '__main__': + + manager = gatt.DeviceManager(adapter_name='hci0') + device = FTMS_Rower(mac_address="", manager=manager) + device.connect() + + manager.run() + + # manager = FTMS_Row_ManagerRowManager(adapter_name='hci0') + # manager.start_discovery() + # try: + # manager.run() + # except KeyboardInterrupt: + # for device in manager.devices(): + # if device.is_connected(): + # device.disconnect() + # manager.stop() diff --git a/src/adapters/ftmsrower/ftmsrowtobleant.py b/src/adapters/ftmsrower/ftmsrowtobleant.py new file mode 100755 index 0000000..0b0cbc2 --- /dev/null +++ b/src/adapters/ftmsrower/ftmsrowtobleant.py @@ -0,0 +1,184 @@ +import logging +import struct + +import gatt +import threading +from time import sleep +import time +from copy import deepcopy + +from . import ftmsrowerreader + +logger = logging.getLogger(__name__) + + +class DataLogger(): + # INDEXES are [index, length] + # referencing later by [index:index + length] notation + # All little endian? + # +- Strokes per 1/2 second + # | +--+- Total Strokes + # | | | +--+--+- Total Distance + # | | | | | | +--+- Instantaneous Pace + # | | | | | | | | +--+- Instanteous Power + # | | | | | | | | | | +--+- Total Energy + # | | | | | | | | | | | | +--+- Energy_per_hour + # | | | | | | | | | | | | | | +- Energy Per Minute + # | | | | | | | | | | | | | | | +--+- Elapsed time + # | | | | | | | | | | | | | | | | | + # 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Index + # 2c-09-32-03-00-2b-00-00-95-00-83-00-09-00-f1-02-00-12-00 + INDEXES = { + "stroke_rate": [2, 1], # /2 for StrokesPerSecond + "total_strokes": [3, 2], # Total, 02 00 = 00 02 = 2 + "total_distance_m": [5, 3], # Total, Meters 1a 00 00 = 00 00 1a = 26 + "instantaneous pace": [8, 2], # Gauge, c4 00 = 00 c4 = 196 seconds per 500 meters + "watts": [10, 2], # Gauge, 50 00 = 00 50 = 80 watts (instantaneous_power) + "total_kcal": [12, 2], # Total, kCal 07 00 = 00 07 = 7 kCal + "total_kcal_hour": [14, 2], # Gauge? Total? Total if we continue for 1 hr? + "total_kcal_minute": [16, 1], # Gauge/Avg, once at least a minute is done + #"elapsed_time": [17, 3], # Total reported, but apparently we calculate it? + } + FTMS_EVENT = 44 + + def __init__(self, rower_interface): + self._rower_interface = rower_interface + self._rower_interface.register_callback(self.on_row_event) + + self.WRValues_rst = None + self.WRValues = None + self.WRValues_standstill = None + self.starttime = None + self.fullstop = None + self.ftmsHalt = None + + self._reset_state() + + def _reset_state(self): + self.WRValues_rst = { + 'stroke_rate': 0, + 'total_strokes': 0, + 'total_distance_m': 0, + 'instantaneous pace': 0, + 'speed': 0, + 'watts': 0, + 'total_kcal': 0, + 'total_kcal_hour': 0, + 'total_kcal_min': 0, + 'heart_rate': 0, + 'elapsedtime': 0.0, + 'work': 0, + 'stroke_length': 0, + 'force': 0, + 'watts_avg':0, + 'pace_avg':0 + } + self.WRValues = deepcopy(self.WRValues_rst) + self.WRValues_standstill = deepcopy(self.WRValues_rst) + self.starttime = None # time.time() # was None + self.fullstop = True + self.ftmsHalt = False + self.Initial_reset = False + + + def elapsedtime(self): + print(self.fullstop) + if self.fullstop == False: + elaspedtimecalc = int(time.time() - self.starttime) + self.WRValues.update({'elapsedtime':elaspedtimecalc}) + elif self.fullstop == True and self.WRValues.get('total_distance_m') !=0 and self.Initial_reset == True: + if not self.starttime: + self.starttime = time.time() + elaspedtimecalc = int(time.time() - self.starttime) + self.WRValues.update({'elapsedtime':elaspedtimecalc}) + else: + self.WRValues.update({'elapsedtime': 0}) + + def on_row_event(self, event): + if int(event[0]) == self.FTMS_EVENT: + oldWRValues = deepcopy(self.WRValues) + for message_type, indexes in self.INDEXES.items(): + i, l = indexes + # Get the data at the index(es), reverse the byte order + # Convert the bytestring to a hex string, then to a decimal integer + integer_value = int(event[i:i+l][::-1].hex(),16) + #if message_type = "stroke_rate": + # integer_value = int(integer_value / 2) # Handled in antfe.py + self.WRValues.update({message_type: integer_value}) + if message_type == "instantaneous pace" and integer_value > 0: + # pace is seconds per 500m + # We want cm/s? I guess? So 500 (meters) * 100 gives us CM + # Then divide by pace for cm/s + speed = 500 * 100 / integer_value + self.WRValues.update({'speed': speed}) + new_strokes = self.WRValues['total_strokes'] - oldWRValues['total_strokes'] + new_distance = self.WRValues['total_distance_m'] - oldWRValues['total_distance_m'] + if new_strokes > 0: + self.WRValues.update({'stroke_length': new_distance / new_strokes}) + self.elapsedtime() + + print(self.WRValues) + print("| H|R| TS| TD| IP| Wa| TE| TH|M|time") + print(event.hex()) + + +def connectFTMS(manager,ftms): + ftms.connect() + manager.run() + +def reset(ftms): + pass + ftms.characteristic_write_value(struct.pack(" Date: Mon, 20 Oct 2025 21:18:45 +0100 Subject: [PATCH 2/6] restored install.sh from upstream --- install.sh | 45 ++++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/install.sh b/install.sh index 41776ec..126b198 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,7 @@ echo "-------------------------------------------------------------" echo "updates the list of latest updates available for the packages" echo "-------------------------------------------------------------" echo " " -#sudo apt-get update +sudo apt-get update echo " " echo "----------------------------------------------" @@ -34,22 +34,22 @@ echo "install needed packages for python " echo "----------------------------------------------" echo " " -#sudo apt-get install -y \ -# python3 \ -# python3-gi \ -# python3-gi-cairo \ -# gir1.2-gtk-3.0 \ -# python3-pip \ -# libatlas-base-dev \ -# libdbus-1-dev \ -# libglib2.0-dev \ -# libgirepository1.0-dev \ -# libcairo2-dev \ -# zlib1g-dev \ -# libfreetype6-dev \ -# liblcms2-dev \ -# libopenjp2-7 \ -# libtiff5 +sudo apt-get install -y \ + python3 \ + python3-gi \ + python3-gi-cairo \ + gir1.2-gtk-3.0 \ + python3-pip \ + libatlas-base-dev \ + libdbus-1-dev \ + libglib2.0-dev \ + libgirepository1.0-dev \ + libcairo2-dev \ + zlib1g-dev \ + libfreetype6-dev \ + liblcms2-dev \ + libopenjp2-7 \ + libtiff6 echo " " echo "----------------------------------------------" @@ -57,9 +57,8 @@ echo "set up virtual environment " echo "----------------------------------------------" echo " " -#python3 -m venv venv -#sleep 10 -#source venv/bin/activate +python3 -m venv venv +source venv/bin/activate echo " " echo "----------------------------------------------" @@ -67,8 +66,8 @@ echo "install needed python3 modules for the project " echo "----------------------------------------------" echo " " -#pip install --upgrade pip -#pip install -r requirements.txt +pip install --upgrade pip +pip install -r requirements.txt echo " " echo "-------------------------------------------------------" @@ -171,7 +170,7 @@ echo " setup screen setting to start up at boot " echo "------------------------------------------------------------" echo " " -sudo sed -i 's/#dtparam=spi=on/dtparam=spi=on/g' /boot/config.txt +sudo sed -i 's/#dtparam=spi=on/dtparam=spi=on/g' /boot/firmware/config.txt cp src/adapters/screen/settings.ini.orig src/adapters/screen/settings.ini sudo sed -i 's@#REPO_DIR#@'"$repo_dir"'@g' src/adapters/screen/settings.ini From 601c8f1d949bcd937442832906d177fd53d75d7c Mon Sep 17 00:00:00 2001 From: ghotighola-collab Date: Mon, 20 Oct 2025 21:24:35 +0100 Subject: [PATCH 3/6] change `Cityrow: cr` -> `FTMS Rower: ftms` in parser argument help --- src/waterrowerthreads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/waterrowerthreads.py b/src/waterrowerthreads.py index 409522a..ba84a0b 100644 --- a/src/waterrowerthreads.py +++ b/src/waterrowerthreads.py @@ -146,7 +146,7 @@ def ANTService(in_q, ant_in_q): if __name__ == '__main__': try: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter, ) - parser.add_argument("-i", "--interface", choices=["ftms","s4","sr"], default="s4", help="choose Waterrower interface S4 monitor: s4 or Smartrow: sr or Cityrow: cr") + parser.add_argument("-i", "--interface", choices=["ftms","s4","sr"], default="s4", help="choose Waterrower interface S4 monitor: s4 or Smartrow: sr or FTMS Rower: ftms") parser.add_argument("-b", "--blue", action='store_true', default=False,help="Broadcast Waterrower data over bluetooth low energy") parser.add_argument("-a", "--antfe", action='store_true', default=False,help="Broadcast Waterrower data over Ant+") args = parser.parse_args() From 73f1c0414b5153171c6684ae4db0bbd42cbb79a1 Mon Sep 17 00:00:00 2001 From: ghotighola-collab Date: Mon, 20 Oct 2025 21:38:37 +0100 Subject: [PATCH 4/6] comment debugging print statements --- src/adapters/ftmsrower/ftmsrowtobleant.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/adapters/ftmsrower/ftmsrowtobleant.py b/src/adapters/ftmsrower/ftmsrowtobleant.py index 0b0cbc2..9b7359b 100755 --- a/src/adapters/ftmsrower/ftmsrowtobleant.py +++ b/src/adapters/ftmsrower/ftmsrowtobleant.py @@ -20,7 +20,7 @@ class DataLogger(): # | +--+- Total Strokes # | | | +--+--+- Total Distance # | | | | | | +--+- Instantaneous Pace - # | | | | | | | | +--+- Instanteous Power + # | | | | | | | | +--+- Instantaneous Power (watts) # | | | | | | | | | | +--+- Total Energy # | | | | | | | | | | | | +--+- Energy_per_hour # | | | | | | | | | | | | | | +- Energy Per Minute @@ -118,8 +118,8 @@ def on_row_event(self, event): self.elapsedtime() print(self.WRValues) - print("| H|R| TS| TD| IP| Wa| TE| TH|M|time") - print(event.hex()) + #print("| H|R| TS| TD| IP| Wa| TE| TH|M|time") + #print(event.hex()) def connectFTMS(manager,ftms): From 47ff74888513decf1a46c2063dd2f4808c178b8b Mon Sep 17 00:00:00 2001 From: ghotighola-collab Date: Mon, 20 Oct 2025 22:46:07 +0100 Subject: [PATCH 5/6] fix error in update to naming standard --- src/waterrowerthreads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/waterrowerthreads.py b/src/waterrowerthreads.py index ba84a0b..4fe1f0a 100644 --- a/src/waterrowerthreads.py +++ b/src/waterrowerthreads.py @@ -113,7 +113,7 @@ def ANTService(in_q, ant_in_q): if args.interface == "ftms": logger.info("interface ftms will be used for data input") - t = threading.Thread(target=FTMS_rower, args=(q, ble_q, ant_q)) + t = threading.Thread(target=FTMSrower, args=(q, ble_q, ant_q)) t.daemon = True t.start() threads.append(t) From 26b2e3aabf53646e9e2c6789d5be19594ab74f01 Mon Sep 17 00:00:00 2001 From: ghotighola-collab Date: Wed, 22 Oct 2025 23:13:03 -0600 Subject: [PATCH 6/6] Update ftmsrowtobleant.py --- src/adapters/ftmsrower/ftmsrowtobleant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/ftmsrower/ftmsrowtobleant.py b/src/adapters/ftmsrower/ftmsrowtobleant.py index 9b7359b..626295a 100755 --- a/src/adapters/ftmsrower/ftmsrowtobleant.py +++ b/src/adapters/ftmsrower/ftmsrowtobleant.py @@ -16,7 +16,7 @@ class DataLogger(): # INDEXES are [index, length] # referencing later by [index:index + length] notation # All little endian? - # +- Strokes per 1/2 second + # +- Strokes per 2 seconds # | +--+- Total Strokes # | | | +--+--+- Total Distance # | | | | | | +--+- Instantaneous Pace